# 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.
## Endpoints
`GET /sync/playback` — list saved paused playbacks for a user (or narrow with `/sync/playback/:type` where `:type` is `episodes` or `movies`). Filter by `date_from`, `date_to`, `hide_watched`, `limit`.
`DELETE /sync/playback/:id` — remove a saved playback by its ID.
## Item shape
Each playback in the response includes:
* `id` — 64-bit integer playback ID (use this with the DELETE endpoint)
* `progress` — percentage 0-100 (e.g. `42`, `75.5`)
* `paused_at` — ISO-8601 UTC timestamp
* `type` — `"episode"` or `"movie"`
* For episodes: `episode.{season, number, title}` plus `tvdb_season` / `tvdb_number` for anime
* The container object: `show` (TV episodes), `anime` (anime episodes), or `movie`. Each carries `title`, `year`, and `ids`.
## Related
`POST /sync/history` — the canonical "mark as watched" endpoint. Use this (not playback) when you want a title on the user's watched library.
Pick the right write endpoint when you don't need real-time playback tracking.
The full scrobble lifecycle, including how pause/stop create playbacks.
API reference index for the four scrobble endpoints.
`POST /sync/activities` — the "is anything new?" gate. Check the `playback` timestamp before refetching.
The activities-driven refresh strategy applied across all user data.
# About Ratings
Source: https://api.simkl.org/api-reference/ratings
Where to read Simkl's community ratings, IMDb / MAL scores, drop rates, and rank — for any title in the catalog or just the items in a user's watchlist.
## Single-title ratings — use the detail endpoint
Per-title ratings (Simkl community average + votes, IMDb rating, MAL
rating + rank) are returned **as part of the catalog detail
endpoints**. There's no separate read-only "ratings by ID" endpoint —
the same data lives inside the `ratings` field of every detail
response.
| Have | Use |
| --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **A Simkl ID** | [`GET /movies/{id}`](/api-reference/simkl/get-movie) · [`GET /tv/{id}`](/api-reference/simkl/get-tv-show) · [`GET /anime/{id}`](/api-reference/simkl/get-anime). All three are Cloudflare-cached, so repeat reads are near-free. |
| **An external ID** (IMDb / TMDB / TVDB / MAL / AniDB / …) | First resolve to a Simkl ID via [`GET /redirect`](/api-reference/simkl/redirect) (returns a `301` with the canonical Simkl URL), then call the detail endpoint above. The redirect itself is a tiny 301 with no body; the detail call hits the CDN cache. |
The `ratings` block on every detail response looks like:
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"ratings": {
"simkl": { "rating": 8.6, "votes": 11487 },
"imdb": { "rating": 8.8, "votes": 2817264 },
"mal": { "rating": 8.6, "votes": 3007340, "rank": 99 }
}
}
```
Each provider key is present **only when Simkl has data on file**.
Live-action titles typically carry `simkl + imdb`; modern anime carry
`simkl + mal`; classic anime sometimes carry all three.
## Bulk ratings for a user's watchlist
If you need the Simkl community rating + drop rate for **every title
in the user's watchlist** (one type at a time), use:
`?user_watchlist=watching` (or `plantowatch`, `hold`, `completed`, `dropped`, comma-separated, or `1` for all). Returns an array of `{id, simkl: {rating, votes, droprate}}` for every item in those watchlist buckets. Requires a Bearer token. Useful for "decide what to watch next from my plantowatch list" UIs.
For the user's **own** ratings (the 1-10 scores they've personally
assigned), use the [Sync endpoints](/api-reference/sync) under
`/sync/ratings`.
## Aggregate ratings on `simkl.com`
Public catalog pages on `simkl.com` (e.g. [`/movies/472214/inception`](https://simkl.com/movies/472214/inception)) render the same ratings block plus the user's reactions and reviews. If your app wants to deep-link rather than render, see the [Deep-linking guide](/guides/deep-linking).
# Redirect & deep-linking
Source: https://api.simkl.org/api-reference/redirect
Two jobs: link from anywhere into the right Simkl page (no API call), or resolve any external ID to a Simkl ID at the lowest possible cost.
`GET /redirect` is a passive helper that **`301`-redirects** to a Simkl URL given any combination of identifiers. It's designed for two distinct situations — pick the one that matches your job.
## Two use cases
You have an external ID or a title and you just want to send the user to the matching Simkl page (or trailer / share / mark-watched action). No `client_id`, no JSON parsing.
You have an IMDB / TMDB / TVDB / MAL ID and need the **Simkl ID** so you can call `/movies/:id`, `/tv/:id`, or `/anime/:id`. `/redirect` is the lowest-overhead way to get there — read one header, no JSON parsing.
Always returns `301 Moved Permanently` with a `Location` header and `Cache-Control: no-store`. The `type=show` value matches both `tv` and `anime`. Like every Simkl endpoint, requests must include the [required URL parameters](/conventions/headers#required-url-parameters) (`client_id`, `app-name`, `app-version`) and a `User-Agent` header. No `Authorization` token is needed except for `to=watched`, which signs the user in if they aren't already.
**Do not follow the 301.** This applies to **HTTP clients, scrapers, server-side fetchers, automated tools, AI agents, and LLM-driven workflows alike** — the information you need is in the **`Location` response header**, never in the destination body. The destination is one of:
* A human-facing simkl.com page (HTML),
* A YouTube trailer page,
* A `twitter.com/intent/tweet` URL,
* A `/oauth/authorize` URL (for `to=watched` flows when the user isn't signed in).
None of those contain API data. Fetching them wastes bandwidth, can break (CORS / auth / rate limits on the destination host), and gives you nothing useful. **The Simkl URL you want — and the `simkl_id` you can parse out of its path — is in the redirect target string, available without ever following the redirect.**
Configure your HTTP client to stop at the 301 and read the header:
| Tool / language | How to stop at the 301 |
| ----------------- | ------------------------------------------------------------------------------- |
| `curl` | `curl -I ` (HEAD request) or `curl --max-redirs 0 -s -D - ` |
| Python `requests` | `requests.get(url, allow_redirects=False)` then `r.headers['location']` |
| Python `httpx` | `httpx.get(url, follow_redirects=False)` |
| Node `fetch` | `fetch(url, { redirect: 'manual' })` then `r.headers.get('location')` |
| Node `axios` | `axios.get(url, { maxRedirects: 0, validateStatus: s => s === 301 })` |
| Go `net/http` | Set `client.CheckRedirect = func(...) error { return http.ErrUseLastResponse }` |
This is **the canonical way to use `/redirect`** — not a perf optimisation. Following the redirect defeats the endpoint's purpose.
**What to do after reading the `Location` header — pick one:**
* **You only need the Simkl ID.** Parse it out of the URL path and **stop here**. Don't call anything else. Example: `Location: https://simkl.com/tv/17465/game-of-thrones` → `simkl_id = 17465`. You're done.
* **You also need the full record** (title, overview, poster, fanart, ratings, trailers, etc.). Use the parsed Simkl ID to call the matching detail endpoint, which is **Cloudflare-cached by Simkl ID** — popular titles come straight from edge cache and are near-free:
* Movies → [`GET /movies/{simkl_id}`](/api-reference/simkl/get-movie)
* TV shows → [`GET /tv/{simkl_id}`](/api-reference/simkl/get-tv-show)
* Anime → [`GET /anime/{simkl_id}`](/api-reference/simkl/get-anime)
* Episode lists → [`GET /tv/episodes/{simkl_id}`](/api-reference/simkl/get-tv-episodes) or [`GET /anime/episodes/{simkl_id}`](/api-reference/simkl/get-anime-episodes)
Two HTTP requests max (one to `/redirect` for the ID, one to the cached detail endpoint for the data). Never follow the 301 from `/redirect` itself.
## Use case 1 — Link to Simkl when you don't have a Simkl ID
You're rendering a clickable link in a newsletter, a browser-extension menu, a "Share" button, or any external surface, and you'd rather not call the JSON API yourself. Hand `/redirect` whatever identifier you have on hand and it sends the user to the right place.
### Action modes (`to=`)
| Mode | Redirects to |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `simkl` *(default)* | The Simkl page for the resolved item. |
| `trailer` | The trailer URL (typically YouTube). |
| `twitter` | A `twitter.com/intent/tweet` URL with the title and a Simkl link prefilled. |
| `watched` | Marks the item watched on the user's account. If the user isn't signed in, Simkl first redirects through `/oauth/authorize`, then performs the action and lands them on the page. |
### Recipes
```bash IMDB → Simkl page theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -I "https://api.simkl.com/redirect?to=simkl&imdb=tt0944947&client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
# 301 Location: https://simkl.com/tv/17465/game-of-thrones
```
```bash TMDB → Simkl page (movie) theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -I "https://api.simkl.com/redirect?to=simkl&type=movie&tmdb=27205&client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
# 301 Location: https://simkl.com/movies/472214/inception
```
\`\`\`bash TMDB → Simkl page (TV — `type` is required)
curl -I "[https://api.simkl.com/redirect?to=simkl\&type=tv\&tmdb=1399\&client\_id=YOUR\_CLIENT\_ID\&app-name=my-app-name\&app-version=1.0](https://api.simkl.com/redirect?to=simkl\&type=tv\&tmdb=1399\&client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0)" \
-H "User-Agent: my-app-name/1.0"
# 301 Location: [https://simkl.com/tv/17465/game-of-thrones](https://simkl.com/tv/17465/game-of-thrones)
````
```bash TMDB → specific episode
curl -I "https://api.simkl.com/redirect?to=simkl&type=tv&tmdb=1399&season=1&episode=3&client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
# 301 Location: https://simkl.com/tv/17465/.../season-1/episode-3
````
```bash MAL → Simkl anime page theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -I "https://api.simkl.com/redirect?to=simkl&mal=11757&client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
# 301 Location: https://simkl.com/anime/37226/sword-art-online
```
```bash Title + year → Simkl page theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -I "https://api.simkl.com/redirect?to=simkl&type=movie&title=Inception&year=2010&client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
# 301 Location: https://simkl.com/movies/472214/inception
```
```bash Trailer theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -I "https://api.simkl.com/redirect?to=trailer&imdb=tt0944947&client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
# 301 Location: https://www.youtube.com/watch?v=...
```
```bash Tweet a movie theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -I "https://api.simkl.com/redirect?to=twitter&type=movie&title=Inception&year=2010&client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
# 301 Location: https://twitter.com/intent/tweet?text=...
```
```bash Mark as watched (signs the user in if needed) theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -I "https://api.simkl.com/redirect?to=watched&imdb=tt0944947&season=1&episode=3&client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
# 301 Location: https://simkl.com/oauth/authorize?... (returns to the action after login)
```
### Building the Simkl URL yourself
If you already have the Simkl ID and slug (returned in `ids.simkl_id` and `ids.slug` on every standard media object), you don't need `/redirect` at all — assemble the URL directly:
| Resource | URL pattern | | |
| ------------- | -------------------------------------------------------------------------- | ------ | --------- |
| Movie | `https://simkl.com/movies/{simkl_id}/{slug}` | | |
| TV show | `https://simkl.com/tv/{simkl_id}/{slug}` | | |
| Anime | `https://simkl.com/anime/{simkl_id}/{slug}` | | |
| TV season | `https://simkl.com/tv/{simkl_id}/{slug}/season-{N}` | | |
| TV episode | `https://simkl.com/tv/{simkl_id}/{slug}/season-{N}/episode-{M}` | | |
| Anime episode | `https://simkl.com/anime/{simkl_id}/{slug}/episode-{M}` | | |
| User profile | `https://simkl.com/{username}` | | |
| User stats | `https://simkl.com/{username}/stats/` | | |
| User library | \`[https://simkl.com/\{username}/\{tv](https://simkl.com/\{username}/\{tv) | movies | anime}/\` |
The `slug` is **technically optional, but always include it when you have it.** If you skip it, Simkl runs an extra title lookup and `301`-redirects to the slugged URL anyway — wasted server time on Simkl's side and an extra round-trip on yours. The `slug` is returned in `ids.slug` on every standard media object — store it alongside the Simkl ID and reuse.
***
## Use case 2 — Resolve an external ID to a Simkl ID
You have an external ID (IMDB, TMDB, TVDB, MAL, AniDB, AniList, Kitsu, etc.) and you need the **Simkl ID** so you can fetch the full record from `/movies/{id}`, `/tv/{id}`, or `/anime/{id}`. **This is the recommended path** — `/redirect` returns a tiny redirect with the Simkl ID baked into the URL, and the detail endpoints are aggressively cached on Cloudflare by Simkl ID.
### How `/redirect` resolves an ID
The response is a `301` with a `Location` header pointing at the canonical Simkl URL, e.g. `https://simkl.com/tv/17465/game-of-thrones`. Parse out `17465` and pass it to `/tv/17465` to get the full record from Cloudflare cache.
* **Tiny payload** — just HTTP headers, no JSON to parse.
* **Cached path** — the follow-up detail call (`/movies/{id}`, `/tv/{id}`, `/anime/{id}`) hits Cloudflare's edge cache, so it stays cheap for repeat lookups of the same title.
* **Same required params** as every Simkl endpoint — see [Headers and required parameters](/conventions/headers#required-url-parameters).
### How to extract the Simkl ID from the redirect
The `Location` header looks like one of:
```
https://simkl.com/movies/{simkl_id}/{slug}
https://simkl.com/tv/{simkl_id}/{slug}
https://simkl.com/anime/{simkl_id}/{slug}
```
The numeric segment immediately after `/movies/`, `/tv/`, or `/anime/` is the Simkl ID. Then call the summary endpoint of your choice with that ID.
### Recipes
```bash IMDB → Simkl ID → /tv/:id theme={"theme":{"light":"github-light","dark":"vesper"}}
PARAMS="client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0"
UA="my-app-name/1.0"
SIMKL_URL=$(curl -sI "https://api.simkl.com/redirect?to=simkl&imdb=tt0944947&$PARAMS" \
-H "User-Agent: $UA" \
| awk -v IGNORECASE=1 '/^location:/ {print $2}' | tr -d '\r\n')
SIMKL_ID=$(echo "$SIMKL_URL" | awk -F/ '{print $5}')
curl "https://api.simkl.com/tv/$SIMKL_ID?$PARAMS" \
-H "User-Agent: $UA"
```
```js IMDB → Simkl ID → /movies/:id theme={"theme":{"light":"github-light","dark":"vesper"}}
const PARAMS = `client_id=${CLIENT_ID}&app-name=my-app-name&app-version=1.0`;
const HEADERS = { 'User-Agent': 'my-app-name/1.0' };
const loc = await fetch(
`https://api.simkl.com/redirect?to=simkl&imdb=tt1375666&${PARAMS}`,
{ redirect: 'manual', headers: HEADERS }
).then(r => r.headers.get('location'));
// loc === "https://simkl.com/movies/472214/inception"
const simklId = loc.match(/\/(?:movies|tv|anime)\/(\d+)\//)[1];
const movie = await fetch(
`https://api.simkl.com/movies/${simklId}?${PARAMS}`,
{ headers: HEADERS }
).then(r => r.json());
```
```python MAL → Simkl ID → /anime/:id theme={"theme":{"light":"github-light","dark":"vesper"}}
import re, requests
PARAMS = {
'client_id': CLIENT_ID,
'app-name': 'my-app-name',
'app-version': '1.0',
}
HEADERS = {'User-Agent': 'my-app-name/1.0'}
# 1. Resolve MAL ID to a Simkl URL — read the Location header, no JSON parse
loc = requests.get(
'https://api.simkl.com/redirect',
params={**PARAMS, 'to': 'simkl', 'mal': 11757},
headers=HEADERS,
allow_redirects=False,
).headers['location']
# 2. Extract the Simkl ID from the URL path
simkl_id = int(re.search(r'/(?:movies|tv|anime)/(\d+)/', loc).group(1))
# 3. Pull the full anime record (summary endpoints auto-include extended data)
anime = requests.get(
f'https://api.simkl.com/anime/{simkl_id}',
params=PARAMS,
headers=HEADERS,
).json()
```
**Cache the resolved Simkl ID.** External IDs map to Simkl IDs once and rarely change. Store the mapping locally so the next request goes straight to `/movies/{id}` / `/tv/{id}` / `/anime/{id}` without a round-trip through `/redirect`.
***
## What you can pass
`/redirect` accepts a wide set of identifiers — pass any combination, the more the better:
Any of the [supported ID keys](/conventions/standard-media-objects#supported-id-keys) — `simkl`, `imdb`, `tmdb` (pair with `type=movie` or `type=tv` — TMDB has no anime type), `tvdb`, `mal`, `anidb`, `crunchyroll`, etc. Most stand alone.
`title=...&year=...&type=...`. Title-based fallback for when you have nothing else.
`season` (defaults to `1`) and `episode`. Setting either one excludes movies from the search.
`ep_title` is used when `to=twitter` to include the episode title in the tweet body.
## Endpoint reference
Full parameter list, response codes, and an interactive playground.
## Related
Where the `ids.simkl_id` and `ids.slug` fields come from.
Once you have the Simkl ID, fetch the full record from `/movies/{id}`, `/tv/{id}`, or `/anime/{id}` (Cloudflare-cached by ID).
`to=watched` first redirects through `/oauth/authorize` for unauthenticated users.
# How scrobbling works
Source: https://api.simkl.org/api-reference/scrobble
Report real-time playback — start, pause, stop, checkin — so Simkl tracks 'Watching now' and auto-marks items watched.
Scrobbling lets apps report **real-time playback events**. Use it when a user starts, pauses, or stops watching. If you just want a "Mark as watched" button without tracking playback, use [`POST /sync/history`](/api-reference/simkl/add-to-history) instead.
This page is a reference index. The lifecycle, decision tree, platform recipes, and gotchas live in the Scrobble guide:
Mermaid state diagram of the lifecycle, "checkin vs scrobble loop" decision tree, universal player-event mapping table, reference Scrobbler implementations in Python / JavaScript / Swift / TypeScript, seek/scrub handling, and a gotchas FAQ.
Pass as much data as you can (title, year, `ids`) so Simkl can detect the item reliably.
`progress` is a percentage from `0.00` to `100.00`. The input format is flexible (max 2 decimal places — `75`, `75.0`, `75.12` are all fine); responses are standardized (`75`, `75.12`, `45.5`). Scrobble IDs are 64-bit integers.
**Send scrobble events only on real user actions** — pressed Play, Paused, Stopped — with the current `progress` percentage. **Do not poll** `/scrobble/*` every few seconds or minutes. Simkl automatically advances the progress between events using the item's known runtime, so periodic re-posting wastes API quota and can trigger rate limits.
## What each endpoint does
| Endpoint | Effect |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`POST /scrobble/start`](/api-reference/simkl/scrobble-start) | Marks the user as **"Watching now"** on their profile. Does **not** mark the item as watched. |
| [`POST /scrobble/pause`](/api-reference/simkl/scrobble-pause) | Saves the current `progress` so the user can resume later. |
| [`POST /scrobble/stop`](/api-reference/simkl/scrobble-stop) | Ends the session. With `progress` ≥ 80%, marks the item watched; below that, the session is kept as a resumable pause. |
| [`POST /scrobble/checkin`](/api-reference/simkl/scrobble-checkin) | Like [`start`](/api-reference/simkl/scrobble-start), but Simkl **auto-marks the item watched when its computed progress reaches 100%** based on the item's runtime — no further calls needed from your app. Use this when you can't reliably hook into stop events. |
**Start ≠ watched.** A bare [`start`](/api-reference/simkl/scrobble-start) only puts the title in the user's "Watching now" banner. The item gets marked as watched only when:
* you call [`stop`](/api-reference/simkl/scrobble-stop) with progress ≥ 80%, **or**
* you used [`checkin`](/api-reference/simkl/scrobble-checkin) and the auto-tracked progress reaches 100%, **or**
* you separately call [`POST /sync/history`](/api-reference/simkl/add-to-history).
## How it works
Simkl stores **one active scrobble session per show/movie/anime**. Calling `/scrobble/start` **replaces any existing session** for that item and **clears previous pauses**. Paused sessions (created by `/scrobble/pause`, or by `/scrobble/stop` with progress under 80%) can be retrieved via [Get Playbacks](/api-reference/simkl/get-playback-sessions) and resumed by calling `/scrobble/start` again with the same item. To delete a paused session, use [Delete Playback](/api-reference/simkl/delete-playback).
### Lifecycle at a glance
Progress drives every transition. The 80% threshold is the only "magic number" you have to remember.
```
progress ≥ 80
┌──────────────► action: scrobble (marked watched)
start ──► pause ──► start ──► stop ┤
0% 45% 45% ? │ progress < 80
└──────────────► action: pause (resumable)
checkin ────────────────────────────► (no progress; auto-scrobbles at 100% from runtime)
```
* **Two ways to mark something watched.** Either drive [`start`](/api-reference/simkl/scrobble-start) → [`stop`](/api-reference/simkl/scrobble-stop) ≥ 80% yourself, or fire [`checkin`](/api-reference/simkl/scrobble-checkin) once and let Simkl auto-complete from the item's runtime.
* **`progress` is required for [`start`](/api-reference/simkl/scrobble-start) / [`pause`](/api-reference/simkl/scrobble-pause) / [`stop`](/api-reference/simkl/scrobble-stop)** (defaults to `0` if you omit it). On [`checkin`](/api-reference/simkl/scrobble-checkin) it is silently dropped server-side.
* **`simkl` ID alone is enough** for any of the four endpoints. Title + year + extra IDs help when you don't have a `simkl` ID and need Simkl's matcher to find the item.
### Typical flow
User presses Play in your app → `POST /scrobble/start` (or `/scrobble/checkin` if you want auto-completion). Title appears in the "Watching now" banner.
User pauses → `POST /scrobble/pause` with current `progress`. The session is kept as resumable.
User unpauses → `POST /scrobble/start` again with current `progress`. Same session continues.
User stops or finishes → `POST /scrobble/stop` with current `progress`. ≥ 80% marks watched; lower is kept as a pause.
**Use [`checkin`](/api-reference/simkl/scrobble-checkin) for fire-and-forget tracking** — when you have an item's runtime but no reliable "stop" event (e.g. some embedded players, casting flows). Simkl extrapolates progress from the start time + runtime and marks the item watched automatically when 100% is reached.
Members can view and manage their saved playbacks at [simkl.com/my/history/playback-progress-manager/](https://simkl.com/my/history/playback-progress-manager/).
### Action types in responses
The response `action` field tells you what Simkl did with your call:
| Value | When you'll see it |
| ---------- | ------------------------------------------------------------------------------------------- |
| `start` | Returned by `/scrobble/start` when beginning or resuming. |
| `checkin` | Returned by `/scrobble/checkin` — Simkl will auto-scrobble at 100% from the item's runtime. |
| `pause` | Returned by `/scrobble/pause`, or by `/scrobble/stop` when progress \< 80%. |
| `scrobble` | Returned by `/scrobble/stop` when progress ≥ 80% — item marked as watched. |
### Session management
* **Expiry timestamps:** start = now + remaining runtime; stop = now + 1 hour; pause = now.
* **Persistence:** sessions persist until manually removed or replaced by the next scrobble for that title. Retention by plan: Free 7 days, PRO 30 days, VIP 90 days.
* **Rate limiting:** one scrobble operation per user at a time (20-second lock).
* **Duplicate prevention:** `409` if you try to stop an already-completed session within 1 hour.
### Anime episode numbering
Simkl uses **AniDB** as the primary source for anime data, which numbers seasons/episodes differently from TMDB/TVDB. When you scrobble anime using TMDB season/episode numbers, Simkl maps them to the corresponding AniDB episode. Responses include both the Simkl/AniDB numbers (`season`, `number`) and the original TVDB numbers (`tvdb_season`, `tvdb_number`) for reference.
## Endpoints
`POST /scrobble/start` — show "Watching now"; resume a paused session.
`POST /scrobble/checkin` — auto-mark watched at 100% based on runtime.
`POST /scrobble/pause` — save progress so the user can resume.
`POST /scrobble/stop` — end session; ≥ 80% marks watched.
### Playbacks
`GET /sync/playback` — list saved paused playbacks (cross-device resume; optionally narrow with `/:type` where `:type` is `episodes` or `movies`).
`DELETE /sync/playback/:id` — remove a saved playback by ID.
# About Search
Source: https://api.simkl.org/api-reference/search
Look up items by ID, by title, by file name, or pull a random item.
The Search API lets you find items in Simkl's catalog. All endpoints accept a `client_id` only — no user `token` required — and return [Standard Media Objects](/conventions/standard-media-objects).
**Got an external ID already?** Don't search — use [`/redirect`](/api-reference/redirect) to resolve it to a Simkl ID, then fetch the full record from `/movies/{id}`, `/tv/{id}`, or `/anime/{id}` (Cloudflare-cached by Simkl ID, much cheaper for repeat lookups). Search endpoints are for cases where you only have a title string, a file name, or want a random pick.
| Endpoint | What it does |
| --------------------- | ----------------------------------------------------------------------- |
| `GET /search/{type}` | Free-text search across `movie`, `tv`, or `anime`. |
| `POST /search/file` | Identify a movie or episode from a file name (great for media servers). |
| `POST /search/random` | Pick a random item, optionally filtered by genre, year, rating, etc. |
`GET /search/{type}` — search by title.
`POST /search/file` — identify content from a file name.
`POST /search/random` — pick a random item.
# Add Ratings
Source: https://api.simkl.org/api-reference/simkl/add-ratings
/openapi.json post /sync/ratings
Apply user ratings (1-10) to movies, shows, or anime. Same auth model and batching rules as the rest of the [Sync API](/guides/sync). To **read** ratings back, use [`GET /sync/ratings/:type/:rating`](/api-reference/simkl/get-user-ratings) — see **Read side** 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.
Edit the catalog config, try other recipes, swap the test `client_id` for your own.
```html Browser (vanilla JS, save as index.html) theme={"theme":{"light":"github-light","dark":"vesper"}}
Simkl Trending
Trending on Simkl
Loads simkl-trending-urls.json, wires up SimklTrendingClient, subscribes to all configured catalogs, renders one carousel per row.
```
```js JavaScript (save as simkl-trending-client.js) theme={"theme":{"light":"github-light","dark":"vesper"}}
// === Universal ES module — works in any modern browser AND Node 22+ ===
// This is the single SDK file. Save the entire block below as `simkl-trending-client.js`.
// The "Browser (vanilla JS)" tab above imports from this exact file.
// For Node: also add `"type": "module"` to your package.json (or save as `.mjs`).
//
// Then pass it the URL catalog object — three ways to load that:
// Browser / Deno / CF Workers: const data = await (await fetch('./simkl-trending-urls.json')).json();
// Node 22+ (ESM): import data from './simkl-trending-urls.json' with { type: 'json' };
// Node (CommonJS .cjs) const data = require('./simkl-trending-urls.json');
//
// Then: `new SimklTrendingClient(config, data)` or set `globalThis.SIMKL_TRENDING_URLS = data` first.
// === SIMKL_TRENDING_DEFAULTS — tune to match your app ===
export const SIMKL_TRENDING_DEFAULTS = {
// What to load
supportedTypes: ['movies', 'tv', 'anime', 'dvd'], // movies / tv / anime / dvd — drop ones your app doesn't surface
timeframes: ['today'], // today / week / month (ignored for dvd)
size: 100, // 100 or 500 — use 500 if you render derived catalogs (see "Build your own catalogs" below)
// HTTP — replace with YOUR values
clientId: 'YOUR_CLIENT_ID',
appName: 'my-app-name',
appVersion: '1.0',
userAgent: 'my-app-name/1.0',
requestTimeoutMs: 8_000,
minIntervalMs: 250, // process-wide floor between consecutive requests
fetchRetry: {
maxAttempts: 3,
baseBackoffMs: 1_000,
maxBackoffMs: 16_000,
retryStatuses: [408, 425, 429, 500, 502, 503, 504], // never retry other 4xx — they're permanent
},
// Refresh
regenRetrySeconds: [300, 900, 1_800], // 5 / 15 / 30 min after a 304 (server not regenerated yet)
jitterPercent: 0.05, // ±5% so all clients don't hit the CDN at exactly :00
// Persistent cache. Browser: localStorage[storageKey]. Node: file at persistPath.
// Set BOTH to null to disable. For server-side runtimes with many processes, swap
// this for a Redis / KV adapter (see _persist / _restorePersisted).
storageKey: 'simkl-trending-cache',
persistPath: './.simkl-trending-cache.json',
persistMaxAgeSeconds: 7 * 86_400, // discard cache older than 7d
};
export class SimklTrendingError extends Error {
constructor(message, { status, retryAfterMs, permanent } = {}) {
super(message);
this.name = 'SimklTrendingError';
this.status = status ?? null;
this.retryAfterMs = retryAfterMs ?? null;
this.permanent = Boolean(permanent);
}
}
export class SimklTrendingClient {
constructor(config = {}, data = globalThis.SIMKL_TRENDING_URLS) {
if (!data) throw new SimklTrendingError('Pass the URL catalog (SIMKL_TRENDING_URLS) as the second arg or set it globally.');
this.cfg = { ...SIMKL_TRENDING_DEFAULTS, ...config };
this.urls = data.urls ?? data; // unwrap if { urls: {...}, catalogs: [...] } shape
this.defaultCatalogs = data.catalogs ?? null; // pre-defined rows from the JSON
this._cache = new Map();
this._inflight = new Map();
this._subs = new Map();
this._timers = new Set();
this._abort = new AbortController();
this._stopped = false;
this._lastRequestAt = 0;
}
// === Public API ===
async start() {
await this._restorePersisted();
for (const t of new Set(this._subs.keys())) this._notify(t); // paint immediately from restored cache
for (const entry of this._plan()) this._loop(entry);
}
stop() {
this._stopped = true;
this._abort.abort();
for (const t of this._timers) clearTimeout(t);
this._timers.clear();
}
get(type) {
for (const v of this._cache.values()) if (v.itemsByType[type]) return v.itemsByType[type];
return [];
}
subscribe(type, cb) {
if (!this._subs.has(type)) this._subs.set(type, new Set());
this._subs.get(type).add(cb);
const items = this.get(type);
if (items.length) cb(items); // immediate paint from cache
return () => this._subs.get(type)?.delete(cb);
}
// === Catalogs — rows on the home screen ===
catalogs() { return this._catalogConfigs().map(c => this._buildCatalog(c)); }
subscribeCatalogs(cb) {
const sources = new Set(this._catalogConfigs().map(c => c.source));
const unsubs = [];
let primed = false;
// Coalesce: a combined fetch notifies 3 types (movies/tv/anime) in one tick;
// cache-restore fires per subscribed source. queueMicrotask flushes once per tick.
let queued = false;
const fire = () => {
if (queued) return;
queued = true;
queueMicrotask(() => { queued = false; cb(this.catalogs()); });
};
for (const s of sources) unsubs.push(this.subscribe(s, () => { primed = true; fire(); }));
if (!primed) fire(); // empty cache — skeleton paint
return () => unsubs.forEach(u => u());
}
_catalogConfigs() {
if (Array.isArray(this.cfg.catalogs)) return this.cfg.catalogs; // explicit override wins
if (Array.isArray(this.defaultCatalogs)) return this.defaultCatalogs; // pulled from JSON's catalogs[] (recommended path)
const tf0 = this.cfg.timeframes?.[0] ?? 'today'; // last resort: auto-build
return this.cfg.supportedTypes.map(t => t === 'dvd'
? { id: 'dvd', source: 'dvd' }
: { id: `${t}_${tf0}`, source: t, timeframe: tf0 });
}
_fileTitleFor(source, timeframe) {
const sizeKey = `top_${this.cfg.size}`;
const file = source === 'dvd' ? this.urls.dvd?.[sizeKey] : this.urls?.[source]?.[timeframe]?.[sizeKey];
return file?.title ?? `Trending ${source} on Simkl`;
}
_buildCatalog(c) {
if (!c.id || !c.source) throw new SimklTrendingError('catalogs[] entries need `id` and `source`');
const items = this.get(c.source);
let out = items;
if (typeof c.transform === 'function') { // inline closure (per-app overrides)
out = c.transform(items);
} else if (typeof c.recipe === 'string') { // JSON catalogs reference by name
const fn = SimklTrendingRecipes[c.recipe];
if (typeof fn === 'function') out = fn(items, c.recipe_opts || {});
else console.warn(`SimklTrendingClient: unknown recipe "${c.recipe}" for catalog "${c.id}" — rendering raw items.`);
}
return { id: c.id, title: c.title ?? this._fileTitleFor(c.source, c.timeframe ?? this.cfg.timeframes?.[0] ?? 'today'), items: out };
}
// Call from a background scheduler (node-cron, Cloudflare Cron, AWS EventBridge) for a single pass.
async tick() {
await this._restorePersisted();
for (const entry of this._plan()) {
const cached = this._cache.get(entry.url);
const r = await this._fetch(entry, cached?.lastModified);
if (!r.notModified) this._storeAndNotify(entry, r);
}
await this._persist();
}
// === Internals ===
_requiredQuery() {
const { clientId, appName, appVersion } = this.cfg;
return `client_id=${clientId}&app-name=${encodeURIComponent(appName)}&app-version=${encodeURIComponent(appVersion)}`;
}
_plan() {
const { supportedTypes, timeframes, size } = this.cfg;
if (size !== 100 && size !== 500) throw new SimklTrendingError('size must be 100 or 500');
const key = `top_${size}`;
const tvma = supportedTypes.filter(t => ['movies','tv','anime'].includes(t));
const useCombined = tvma.length >= 2; // 1 round-trip < 2-3 per timeframe
const plan = [];
if (tvma.length) {
for (const tf of timeframes) {
const file = useCombined ? this.urls.combined[tf][key]
: this.urls[tvma[0]][tf][key];
plan.push({ ...file, types: tvma });
}
}
if (supportedTypes.includes('dvd')) plan.push({ ...this.urls.dvd[key], types: ['dvd'] });
return plan;
}
// Process-wide rate-limit floor (so N entries refreshing at once don't fire N simultaneous requests).
async _throttle() {
const gap = Date.now() - this._lastRequestAt;
if (gap < this.cfg.minIntervalMs) await this._sleep((this.cfg.minIntervalMs - gap) / 1_000);
this._lastRequestAt = Date.now();
}
static _parseRetryAfter(value) {
if (!value) return null;
const secs = Number(value);
if (Number.isFinite(secs)) return Math.max(0, secs * 1_000);
const dateMs = Date.parse(value);
return Number.isFinite(dateMs) ? Math.max(0, dateMs - Date.now()) : null;
}
// _fetch — dedup + retry + timeout + conditional revalidation + Retry-After. Normalizes array vs object response into itemsByType.
async _fetch(entry, lastModified) {
const sep = entry.url.includes('?') ? '&' : '?';
const url = entry.url + sep + this._requiredQuery();
if (this._inflight.has(url)) return this._inflight.get(url);
// In Node/Deno/Bun/CF Workers, set If-Modified-Since manually for conditional revalidation.
// In BROWSERS, the manual header would trigger a CORS preflight (If-Modified-Since isn't
// CORS-safelisted), and most CDNs respond to OPTIONS with 405 → preflight fails → fetch
// fails. Instead we use `cache: 'no-cache'` so the browser handles revalidation via its
// own HTTP cache (browser-added If-None-Match / If-Modified-Since don't count as author
// headers and skip preflight). User-Agent is dropped in browsers too — it's a forbidden
// header per Fetch spec and silently ignored.
const isBrowser = typeof window !== 'undefined';
const headers = { 'Accept': 'application/json' };
if (!isBrowser) {
headers['User-Agent'] = this.cfg.userAgent;
if (lastModified) headers['If-Modified-Since'] = lastModified;
}
const { fetchRetry, requestTimeoutMs } = this.cfg;
const run = async () => {
let lastErr;
for (let attempt = 0; attempt < fetchRetry.maxAttempts; attempt++) {
await this._throttle();
const ctl = new AbortController();
const time = setTimeout(() => ctl.abort(), requestTimeoutMs);
const off = () => ctl.abort();
this._abort.signal.addEventListener('abort', off);
try {
const fetchOpts = { headers, signal: ctl.signal };
if (isBrowser) fetchOpts.cache = 'no-cache';
const res = await fetch(url, fetchOpts);
if (res.status === 304) return { itemsByType: null, lastModified, notModified: true };
if (!res.ok) {
const retryAfterMs = SimklTrendingClient._parseRetryAfter(res.headers.get('Retry-After'));
const permanent = !fetchRetry.retryStatuses.includes(res.status);
throw new SimklTrendingError(`${res.status} ${res.statusText} — ${entry.title}`,
{ status: res.status, retryAfterMs, permanent });
}
// In browsers with cache:'no-cache', fetch resolves to 200 + cached body when the
// server returned 304 to the browser's conditional request. Detect that via
// Last-Modified equality so we skip a no-op re-render.
const newLastModified = res.headers.get('Last-Modified');
if (isBrowser && lastModified && newLastModified === lastModified) {
return { itemsByType: null, lastModified, notModified: true };
}
const raw = await res.json();
// Combined endpoint returns {movies, tv, anime}. Keep ALL keys (not just wanted types) so
// a dev who later adds 'anime' to supportedTypes can serve it from the same cached response.
const itemsByType = Array.isArray(raw)
? { [entry.types[0]]: raw } // per-type / dvd → array
: raw; // combined → store full object
return { itemsByType, lastModified: newLastModified, notModified: false };
} catch (e) {
if (this._stopped) throw e;
if (e instanceof SimklTrendingError && e.permanent) throw e; // don't waste backoff on 404/400/etc
lastErr = e;
const honored = (e instanceof SimklTrendingError && e.retryAfterMs != null) ? e.retryAfterMs : null;
const backoff = honored ?? Math.min(fetchRetry.baseBackoffMs * (2 ** attempt), fetchRetry.maxBackoffMs);
const jitter = backoff * (Math.random() * 0.2 - 0.1); // ±10% jitter on retry delay
await this._sleep(Math.max(0, backoff + jitter) / 1_000);
} finally {
clearTimeout(time);
this._abort.signal.removeEventListener('abort', off);
}
}
throw lastErr;
};
const p = run().finally(() => this._inflight.delete(url));
this._inflight.set(url, p);
return p;
}
async _loop(entry) {
let lastModified = this._cache.get(entry.url)?.lastModified ?? null;
let retry = 0;
while (!this._stopped) {
let r;
try { r = await this._fetch(entry, lastModified); }
catch (e) {
if (this._stopped) return;
if (e instanceof SimklTrendingError && e.permanent) { // 404 / 401 / etc — halt this entry's loop
console.error(`${entry.title}: ${e.message} (permanent, halting loop)`);
return;
}
console.error(`${entry.title}:`, e.message);
await this._sleep(30); continue;
}
let wait;
if (r.notModified) {
wait = this.cfg.regenRetrySeconds[Math.min(retry++, this.cfg.regenRetrySeconds.length - 1)];
} else {
this._storeAndNotify(entry, r);
lastModified = r.lastModified;
retry = 0;
await this._persist();
const j = this.cfg.jitterPercent;
wait = Math.round(entry.refresh_seconds * (1 + (Math.random() - 0.5) * 2 * j));
}
await this._sleep(wait);
}
}
_storeAndNotify(entry, r) {
this._cache.set(entry.url, { ...r, types: entry.types, fetchedAt: Date.now() });
for (const t of entry.types) this._notify(t);
}
_notify(type) { const items = this.get(type); for (const cb of (this._subs.get(type) ?? [])) cb(items); }
_sleep(seconds) {
return new Promise(resolve => {
const t = setTimeout(() => { this._timers.delete(t); resolve(); }, seconds * 1_000);
this._timers.add(t);
});
}
// Wipe in-memory + persistent cache and notify subscribers (so UI repaints empty).
// Call client.tick() after this if you want to immediately re-fetch.
async clearCache() {
this._cache.clear();
const ls = this._localStorage();
if (ls && this.cfg.storageKey) { try { ls.removeItem(this.cfg.storageKey); } catch { /* ignore */ } }
if (this.cfg.persistPath && !ls) {
try { const fs = await import('node:fs/promises'); await fs.unlink(this.cfg.persistPath); }
catch { /* no file — fine */ }
}
for (const t of this._subs.keys()) this._notify(t);
}
// Persistence — auto-detects environment. Browser → localStorage. Node → file.
// Swap these two methods (plus clearCache) for a Redis / KV adapter in server-side runtimes.
_localStorage() { try { return globalThis.localStorage ?? null; } catch { return null; } }
async _persist() {
const data = JSON.stringify(Object.fromEntries(this._cache));
const ls = this._localStorage();
if (ls && this.cfg.storageKey) { try { ls.setItem(this.cfg.storageKey, data); } catch { /* quota etc */ } return; }
if (!this.cfg.persistPath) return;
try {
const fs = await import('node:fs/promises');
await fs.writeFile(this.cfg.persistPath, data);
} catch { /* best-effort */ }
}
async _restorePersisted() {
const cutoff = Date.now() - this.cfg.persistMaxAgeSeconds * 1_000;
let raw = null;
const ls = this._localStorage();
if (ls && this.cfg.storageKey) {
try { const s = ls.getItem(this.cfg.storageKey); if (s) raw = JSON.parse(s); } catch { /* corrupt — ignore */ }
}
if (!raw && this.cfg.persistPath && !ls) {
try {
const fs = await import('node:fs/promises');
raw = JSON.parse(await fs.readFile(this.cfg.persistPath, 'utf8'));
} catch { /* no cache yet — fine */ }
}
if (!raw) return;
for (const [url, val] of Object.entries(raw)) {
if (val.fetchedAt && val.fetchedAt >= cutoff) this._cache.set(url, val);
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// SimklTrendingRecipes — the catalog transforms referenced by name from the
// JSON's `catalogs[]` array. Pure functions: `(items, opts?) => items`.
// ─────────────────────────────────────────────────────────────────────────────
export const SimklTrendingRecipes = {
// Sorts
byRank(items) { return [...items].sort((a, b) => (a.rank ?? Infinity) - (b.rank ?? Infinity)); },
// Sorts by plan_to_watch desc — i.e. how many users have added the item to their watchlist.
// Despite the historical "mostAnticipated" name, this fires for already-released titles too
// (Game of Thrones, Avatar, One Piece — all rank high because they have huge backlog appeal).
// `mostWatchlisted` is the accurate name; `mostAnticipated` is kept as an alias for back-compat.
mostWatchlisted(items) { return [...items].sort((a, b) => (b.plan_to_watch ?? 0) - (a.plan_to_watch ?? 0)); },
mostAnticipated(items) { return [...items].sort((a, b) => (b.plan_to_watch ?? 0) - (a.plan_to_watch ?? 0)); }, // alias of mostWatchlisted
newest(items) { return [...items].filter(i => i.release_date).sort((a, b) => +new Date(b.release_date) - +new Date(a.release_date)); },
stickiest(items) { return [...items].sort((a, b) => parseFloat(a.drop_rate ?? 'Infinity') - parseFloat(b.drop_rate ?? 'Infinity')); },
highestRated(items, { source = 'simkl', minVotes = 500 } = {}) {
return items.filter(i => (i.ratings?.[source]?.votes ?? 0) >= minVotes)
.sort((a, b) => (b.ratings?.[source]?.rating ?? 0) - (a.ratings?.[source]?.rating ?? 0));
},
bestBoxOffice(items) { // movies only
const bo = i => { const m = (i.metadata || '').match(/Box office \$([\d.]+)([MBK])/);
return m ? parseFloat(m[1]) * { K: 1e3, M: 1e6, B: 1e9 }[m[2]] : null; };
return items.map(i => ({ i, v: bo(i) })).filter(x => x.v != null).sort((a, b) => b.v - a.v).map(x => x.i);
},
// Composed (multiple signals)
// Hidden gems = well-rated titles OUTSIDE Simkl's top N by all-time popularity. The
// `watched` field on trending feeds is last-24h viewers (not lifetime), so filtering on
// it surfaces mainstream titles (The Office, Interstellar) instead of actual gems.
// `rank` is the right gate: mainstream shows rank low (~50-500), obscure-but-loved
// ones rank in the thousands. Sort by rating so the best gems surface first.
hiddenGems(items, { minRating = 7.5, minVotes = 500, minRank = 2000, source = 'simkl' } = {}) {
return items
.filter(i => (i.rank ?? 0) > minRank &&
(i.ratings?.[source]?.rating ?? 0) >= minRating &&
(i.ratings?.[source]?.votes ?? 0) >= minVotes)
.sort((a, b) => (b.ratings?.[source]?.rating ?? 0) -
(a.ratings?.[source]?.rating ?? 0));
},
criticallyAcclaimed(items, { minRating = 8.5, minVotes = 1000, source = 'simkl' } = {}) {
return items.filter(i => (i.ratings?.[source]?.rating ?? 0) >= minRating &&
(i.ratings?.[source]?.votes ?? 0) >= minVotes)
.sort((a, b) => (b.ratings?.[source]?.rating ?? 0) - (a.ratings?.[source]?.rating ?? 0));
},
marathonWorthy(items, { minEpisodes = 20, maxEpisodes = 100, minRating = 8, source = 'simkl' } = {}) {
return items.filter(i => i.status === 'ended' &&
(i.total_episodes ?? 0) >= minEpisodes && (i.total_episodes ?? 0) <= maxEpisodes &&
(i.ratings?.[source]?.rating ?? 0) >= minRating);
},
crossPlatformConsensus(items, { simklMin = 8, otherMin = 8 } = {}) {
return items.filter(i => (i.ratings?.simkl?.rating ?? 0) >= simklMin &&
((i.ratings?.imdb?.rating ?? i.ratings?.mal?.rating) ?? 0) >= otherMin);
},
massiveAnticipation(items, { minRatio = 300 } = {}) {
return items.filter(i => (i.plan_to_watch ?? 0) / ((i.watched ?? 0) + 1) >= minRatio)
.sort((a, b) => (b.plan_to_watch / (b.watched + 1)) - (a.plan_to_watch / (a.watched + 1)));
},
bestOfNetwork(items, { network, minRating = 7.5, requireEnded = false, source = 'simkl' } = {}) {
return items.filter(i => i.network === network &&
(!requireEnded || i.status === 'ended') &&
(i.ratings?.[source]?.rating ?? 0) >= minRating)
.sort((a, b) => (b.ratings?.[source]?.rating ?? 0) - (a.ratings?.[source]?.rating ?? 0));
},
bestOfGenre(items, { genre, minRating = 7.5, minVotes = 300, source = 'simkl' } = {}) {
return items.filter(i => i.genres?.includes(genre) &&
(i.ratings?.[source]?.rating ?? 0) >= minRating &&
(i.ratings?.[source]?.votes ?? 0) >= minVotes)
.sort((a, b) => (b.ratings?.[source]?.rating ?? 0) - (a.ratings?.[source]?.rating ?? 0));
},
// Filters (all use { ...opts } so they're JSON-describable)
byGenre (items, { genre } = {}) { return items.filter(i => i.genres?.includes(genre)); },
byCountry (items, { country } = {}) { const c = String(country || '').toLowerCase(); return items.filter(i => i.country === c); },
byNetwork (items, { network } = {}) { return items.filter(i => i.network === network); },
byStatus (items, { status } = {}) { return items.filter(i => i.status === status); },
byAnimeType(items, { animeType } = {}) { return items.filter(i => i.anime_type === animeType); },
withTrailer(items) { return items.filter(i => i.trailer); },
ongoing(items) { return items.filter(i => i.status === 'ongoing'); },
recentlyEnded(items) { return items.filter(i => i.status === 'ended'); },
// `status === 'premiere'` flags items whose first episode JUST aired (typically within ~30 days).
// It is NOT an "upcoming premieres" filter — those items have status 'ongoing' or no status set.
justPremiered(items) { return items.filter(i => i.status === 'premiere'); },
premiering(items) { return items.filter(i => i.status === 'premiere'); }, // alias of justPremiered (kept for back-compat)
miniSeries(items, { maxEpisodes = 6 } = {}) { return items.filter(i => (i.total_episodes ?? Infinity) <= maxEpisodes); },
justReleased(items, { withinDays = 30 } = {}) {
const cutoff = Date.now() - withinDays * 86_400_000;
return items.filter(i => i.release_date && +new Date(i.release_date) >= cutoff);
},
inTheatersNow(items, { withinDays = 30 } = {}) { // movies only
const cutoff = Date.now() - withinDays * 86_400_000;
return items.filter(i => i.theater && +new Date(i.theater) >= cutoff);
},
recentlyOnDvd(items, { withinDays = 30 } = {}) { // movies only
const cutoff = Date.now() - withinDays * 86_400_000;
return items.filter(i => i.dvd_date && +new Date(i.dvd_date) >= cutoff);
},
// Era filters — release_date in trending feeds is "MM/DD/YYYY"; the regex grabs the 4-digit year either way.
byYear (items, { year } = {}) { return items.filter(i => +String(i.release_date||'').match(/\d{4}/)?.[0] === year); },
byDecade (items, { decade } = {}) { return items.filter(i => { const y = +String(i.release_date||'').match(/\d{4}/)?.[0]; return y >= decade && y < decade + 10; }); },
currentYear(items) { const y = new Date().getFullYear(); return items.filter(i => +String(i.release_date||'').match(/\d{4}/)?.[0] === y); },
lastYear (items) { const y = new Date().getFullYear()-1; return items.filter(i => +String(i.release_date||'').match(/\d{4}/)?.[0] === y); },
bestOfDecade(items, { decade, source = 'simkl', minVotes = 100 } = {}) { // decade + rating sort, mirrors bestOfNetwork/bestOfGenre
return items.filter(i => { const y = +String(i.release_date||'').match(/\d{4}/)?.[0];
return y >= decade && y < decade + 10 && (i.ratings?.[source]?.votes ?? 0) >= minVotes; })
.sort((a, b) => (b.ratings?.[source]?.rating ?? 0) - (a.ratings?.[source]?.rating ?? 0));
},
quickWatches(items, { maxMinutes = 90 } = {}) { // movies only
const m = s => { if (!s) return Infinity;
const x = s.match(/^(?:(\d+)h)?\s*(?:(\d+)m)?$/);
return x ? ((parseInt(x[1]||'0')*60) + parseInt(x[2]||'0')) || Infinity : Infinity; };
return items.filter(i => m(i.runtime) <= maxMinutes);
},
};
/* === Sample usage (put this in a SEPARATE file — app.js / main.mjs / etc.) ===
import data from './simkl-trending-urls.json' with { type: 'json' };
import { SimklTrendingClient } from './simkl-trending-client.js';
const client = new SimklTrendingClient({ clientId: 'YOUR_CLIENT_ID', appName: 'my-app', appVersion: '1.0' }, data);
client.subscribeCatalogs(rows => {
for (const row of rows) console.log(`${row.title}: ${row.items.length} items`);
});
await client.start();
// process.on('SIGINT', () => { client.stop(); process.exit(0); });
=== Background scheduling (server-side runtimes) ===
For long-running servers, the in-process loop is enough. For serverless / cron-triggered
runtimes (Cloudflare Workers, AWS Lambda, GitHub Actions), wire `await client.tick()` to
your scheduler instead of calling `start()`. For browser usage, see the Browser tab above. */
```
```python Python theme={"theme":{"light":"github-light","dark":"vesper"}}
import email.utils, json, os, random, threading, time, urllib.parse
import requests
# === Load the URL catalog ===
# 1. Save the "Copy-paste JSON" block above as `simkl-trending-urls.json` in your project.
# 2. Load it into SIMKL_TRENDING_URLS:
# with open('simkl-trending-urls.json') as f: SIMKL_TRENDING_URLS = json.load(f)
# Or for a packaged module:
# import importlib.resources, json
# SIMKL_TRENDING_URLS = json.loads(importlib.resources.files('your_pkg').joinpath('simkl-trending-urls.json').read_text())
# You can also pass it explicitly: SimklTrendingClient(urls=urls_dict)
# SimklTrendingClient raises SimklTrendingError if it can't find the catalog in either place.
# === SIMKL_TRENDING_DEFAULTS — tune to match your app ===
SIMKL_TRENDING_DEFAULTS = {
# What to load
'supported_types': ['movies', 'tv', 'anime', 'dvd'], # movies / tv / anime / dvd — drop ones your app doesn't surface
'timeframes': ['today'], # today / week / month (ignored for dvd)
'size': 100, # 100 or 500 — use 500 if you render derived catalogs
# HTTP — replace with YOUR values
'client_id': 'YOUR_CLIENT_ID',
'app_name': 'my-app-name',
'app_version': '1.0',
'user_agent': 'my-app-name/1.0',
'request_timeout_s': 8,
'min_interval_s': 0.25, # process-wide floor between consecutive requests
'fetch_retry': {
'max_attempts': 3,
'base_backoff_s': 1,
'max_backoff_s': 16,
'retry_statuses': [408, 425, 429, 500, 502, 503, 504], # never retry other 4xx — they're permanent
},
# Refresh
'regen_retry_s': [300, 900, 1800], # 5 / 15 / 30 min after a 304 (server not regenerated yet)
'jitter_percent': 0.05, # ±5% so all clients don't hit the CDN at exactly :00
# Persistent cache — set persist_path to None to disable. For server-side runtimes,
# swap _persist / _restore_persisted for a Redis / KV adapter.
'persist_path': './.simkl-trending-cache.json',
'persist_max_age_s': 7 * 86400, # discard cache older than 7d
# Catalogs — the rows on your home screen. Each becomes a {'id', 'title', 'items'} dict
# returned by client.catalogs() / pushed to client.subscribe_catalogs(cb). If None,
# one row per supported_type with the JSON's pre-composed title.
# { 'id', 'source', 'timeframe'?, 'title'?, 'transform'? }
'catalogs': None,
}
class SimklTrendingError(Exception):
def __init__(self, message, status=None, retry_after_s=None, permanent=False):
super().__init__(message)
self.status = status
self.retry_after_s = retry_after_s
self.permanent = bool(permanent)
class SimklTrendingClient:
def __init__(self, config=None, data=None):
data = data or globals().get('SIMKL_TRENDING_URLS')
if not data:
raise SimklTrendingError('Pass the URL catalog (SIMKL_TRENDING_URLS) as the second arg or define it globally.')
self.cfg = {**SIMKL_TRENDING_DEFAULTS, **(config or {})}
self.urls = data.get('urls', data) # unwrap {urls: {...}, catalogs: [...]} shape
self.default_catalogs = data.get('catalogs') # pre-defined rows from JSON
self._cache = {}
self._inflight = {}
self._subs = {}
self._lock = threading.RLock()
self._stopped = threading.Event()
self._threads = []
self._last_request_at = 0.0
# === Public API ===
def start(self):
self._restore_persisted()
with self._lock:
for t in list(self._subs):
self._notify(t) # paint immediately from restored cache
for entry in self._plan():
th = threading.Thread(target=self._loop, args=(entry,), daemon=True)
th.start()
self._threads.append(th)
def stop(self):
self._stopped.set()
# Wipe in-memory + persistent cache and notify subscribers (so UI repaints empty).
# Call client.tick() after this to immediately re-fetch.
def clear_cache(self):
with self._lock:
self._cache.clear()
subs_snapshot = {t: list(cbs) for t, cbs in self._subs.items()}
if self.cfg.get('persist_path'):
try: os.remove(self.cfg['persist_path'])
except OSError: pass # no file — fine
for t, cbs in subs_snapshot.items():
for cb in cbs: cb([])
def get(self, type_):
with self._lock:
for v in self._cache.values():
if type_ in v['items_by_type']:
return v['items_by_type'][type_]
return []
def subscribe(self, type_, cb):
with self._lock:
self._subs.setdefault(type_, []).append(cb)
items = self.get(type_)
if items:
cb(items) # immediate paint from cache
def unsub():
with self._lock:
if cb in self._subs.get(type_, []): self._subs[type_].remove(cb)
return unsub
# Resolve all configured catalogs to [{'id','title','items'}, ...] — TV-home-screen rows.
def catalogs(self):
return [self._build_catalog(c) for c in self._catalog_configs()]
# Subscribe to ALL catalog rows at once. cb([{...}, ...]) fires on every refresh.
def subscribe_catalogs(self, cb):
sources = {c['source'] for c in self._catalog_configs()}
unsubs = []
def fire(_): cb(self.catalogs())
for s in sources:
unsubs.append(self.subscribe(s, fire))
if not any(self.get(s) for s in sources):
cb(self.catalogs()) # empty cache — fire once for skeleton paint
def unsub():
for u in unsubs: u()
return unsub
# Call from a background scheduler (cron, Celery beat, APScheduler) for a single pass.
def tick(self):
self._restore_persisted()
for entry in self._plan():
with self._lock: cached = self._cache.get(entry['url'])
r = self._fetch(entry, cached and cached['last_modified'])
if not r['not_modified']: self._store_and_notify(entry, r)
self._persist()
# === Internals ===
def _required_query(self):
c = self.cfg
return (f"client_id={c['client_id']}"
f"&app-name={urllib.parse.quote(c['app_name'])}"
f"&app-version={urllib.parse.quote(c['app_version'])}")
def _plan(self):
c = self.cfg
if c['size'] not in (100, 500): raise SimklTrendingError('size must be 100 or 500')
key = f"top_{c['size']}"
tvma = [t for t in c['supported_types'] if t in ('movies', 'tv', 'anime')]
use_combined = len(tvma) >= 2 # 1 round-trip < 2-3 per timeframe
plan = []
if tvma:
for tf in c['timeframes']:
f = (self.urls['combined'][tf][key] if use_combined
else self.urls[tvma[0]][tf][key])
plan.append({**f, 'types': tvma})
if 'dvd' in c['supported_types']:
plan.append({**self.urls['dvd'][key], 'types': ['dvd']})
return plan
# Process-wide rate-limit floor (so N entries refreshing at once don't fire N simultaneous requests).
def _throttle(self):
with self._lock:
gap = time.monotonic() - self._last_request_at
if gap < self.cfg['min_interval_s']:
if self._stopped.wait(self.cfg['min_interval_s'] - gap): return False
self._last_request_at = time.monotonic()
return True
@staticmethod
def _parse_retry_after(value):
if not value: return None
try: return max(0.0, float(value))
except (TypeError, ValueError): pass
try:
dt = email.utils.parsedate_to_datetime(value)
return max(0.0, dt.timestamp() - time.time())
except (TypeError, ValueError): return None
# _fetch — dedup + retry + timeout + If-Modified-Since + Retry-After. Normalizes list vs dict into items_by_type.
def _fetch(self, entry, last_modified):
sep = '&' if '?' in entry['url'] else '?'
url = entry['url'] + sep + self._required_query()
with self._lock:
inflight = self._inflight.get(url)
if inflight:
inflight['done'].wait()
if inflight.get('error'): raise inflight['error']
return inflight['result']
done = threading.Event()
slot = {'done': done, 'result': None, 'error': None}
with self._lock: self._inflight[url] = slot
try:
headers = {'User-Agent': self.cfg['user_agent'], 'Accept': 'application/json'}
if last_modified: headers['If-Modified-Since'] = last_modified
retry_cfg = self.cfg['fetch_retry']
last_err = None
for attempt in range(retry_cfg['max_attempts']):
self._throttle()
try:
res = requests.get(url, headers=headers, timeout=self.cfg['request_timeout_s'])
if res.status_code == 304:
slot['result'] = {'items_by_type': None, 'last_modified': last_modified, 'not_modified': True}
return slot['result']
if not res.ok:
permanent = res.status_code not in retry_cfg['retry_statuses']
retry_after_s = self._parse_retry_after(res.headers.get('Retry-After'))
raise SimklTrendingError(f"{res.status_code} {res.reason} — {entry['title']}",
status=res.status_code, retry_after_s=retry_after_s, permanent=permanent)
raw = res.json()
items_by_type = ({entry['types'][0]: raw} if isinstance(raw, list) # per-type / dvd → list
else {t: raw.get(t, []) for t in entry['types']}) # combined → dict, filter to wanted types
slot['result'] = {'items_by_type': items_by_type,
'last_modified': res.headers.get('Last-Modified'),
'not_modified': False}
return slot['result']
except SimklTrendingError as e:
if e.permanent: slot['error'] = e; raise # don't waste backoff on permanent errors
last_err = e
honored = e.retry_after_s if e.retry_after_s is not None else None
backoff = honored if honored is not None else min(retry_cfg['base_backoff_s'] * (2 ** attempt), retry_cfg['max_backoff_s'])
backoff += backoff * (random.random() * 0.2 - 0.1) # ±10% jitter
if self._stopped.wait(max(0, backoff)): raise RuntimeError('stopped')
except Exception as e:
if self._stopped.is_set(): raise
last_err = e
backoff = min(retry_cfg['base_backoff_s'] * (2 ** attempt), retry_cfg['max_backoff_s'])
if self._stopped.wait(backoff): raise RuntimeError('stopped')
slot['error'] = last_err
raise last_err
finally:
done.set()
with self._lock: self._inflight.pop(url, None)
def _loop(self, entry):
with self._lock: c = self._cache.get(entry['url'])
last_modified = c['last_modified'] if c else None
retry = 0
while not self._stopped.is_set():
try:
r = self._fetch(entry, last_modified)
except SimklTrendingError as e:
if self._stopped.is_set(): return
if e.permanent: # 404 / 401 / etc — halt this entry's loop
print(f"{entry['title']}: {e} (permanent, halting loop)")
return
print(f"{entry['title']}: {e}")
if self._stopped.wait(30): return
continue
except Exception as e:
if self._stopped.is_set(): return
print(f"{entry['title']}: {e}")
if self._stopped.wait(30): return
continue
if r['not_modified']:
wait = self.cfg['regen_retry_s'][min(retry, len(self.cfg['regen_retry_s']) - 1)]
retry += 1
else:
self._store_and_notify(entry, r)
last_modified = r['last_modified']
retry = 0
self._persist()
j = self.cfg['jitter_percent']
wait = round(entry['refresh_seconds'] * (1 + (random.random() - 0.5) * 2 * j))
if self._stopped.wait(wait): return
def _store_and_notify(self, entry, r):
with self._lock:
self._cache[entry['url']] = {**r, 'types': entry['types'], 'fetched_at': time.time()}
for t in entry['types']: self._notify(t)
def _notify(self, type_):
with self._lock:
cbs = list(self._subs.get(type_, []))
items = self.get(type_)
for cb in cbs: cb(items)
# === Catalog helpers ===
def _catalog_configs(self):
if self.cfg.get('catalogs'): return self.cfg['catalogs'] # explicit override wins
if self.default_catalogs: return self.default_catalogs # JSON's catalogs (recommended path)
tf0 = (self.cfg.get('timeframes') or ['today'])[0] # last resort: auto-build
out = []
for t in self.cfg['supported_types']:
if t == 'dvd': out.append({'id': 'dvd', 'source': 'dvd'})
else: out.append({'id': f'{t}_{tf0}', 'source': t, 'timeframe': tf0})
return out
def _file_title_for(self, source, timeframe=None):
size_key = f"top_{self.cfg['size']}"
if source == 'dvd':
file = (self.urls.get('dvd') or {}).get(size_key)
else:
file = ((self.urls.get(source) or {}).get(timeframe) or {}).get(size_key)
return (file or {}).get('title', f'Trending {source} on Simkl')
def _build_catalog(self, c):
if not c.get('id') or not c.get('source'):
raise SimklTrendingError("catalogs[] entries need 'id' and 'source'")
items = self.get(c['source'])
transform = c.get('transform')
out = transform(items) if callable(transform) else items
title = c.get('title') or self._file_title_for(c['source'], c.get('timeframe') or (self.cfg.get('timeframes') or ['today'])[0])
return {'id': c['id'], 'title': title, 'items': out}
# Persistence — swap these two methods for a Redis / KV adapter in server-side runtimes.
def _persist(self):
if not self.cfg['persist_path']: return
try:
with self._lock: dump = json.dumps(self._cache)
tmp = self.cfg['persist_path'] + '.tmp'
with open(tmp, 'w') as f: f.write(dump)
os.replace(tmp, self.cfg['persist_path'])
except Exception: pass # best-effort
def _restore_persisted(self):
if not self.cfg['persist_path'] or not os.path.exists(self.cfg['persist_path']): return
try:
with open(self.cfg['persist_path']) as f: raw = json.load(f)
cutoff = time.time() - self.cfg['persist_max_age_s']
with self._lock:
for url, val in raw.items():
if val.get('fetched_at', 0) >= cutoff: self._cache[url] = val
except Exception: pass # no cache yet — fine
# === Run ===
client = SimklTrendingClient()
client.subscribe_catalogs(lambda rows: [print(f"{r['title']}: {len(r['items'])} items") for r in rows])
client.start()
# import signal; signal.signal(signal.SIGINT, lambda *_: (client.stop(), exit(0)))
# === Background scheduling (server-side) ===
# For long-running processes, the in-process threads are enough. For cron-style runtimes
# (Celery beat, APScheduler, GitHub Actions), wire `client.tick()` to your scheduler instead of start().
```
```swift Swift (iOS) theme={"theme":{"light":"github-light","dark":"vesper"}}
import Foundation
// === Load the URL catalog ===
// 1. Save the "Copy-paste JSON" block above as `simkl-trending-urls.json` in your app bundle (e.g.
// drag it into Xcode and tick "Copy items if needed" + your app target's "Copy Bundle Resources").
// 2. Load it into a global `SIMKL_TRENDING_URLS: [String: Any]` (and pass it to the constructor, or
// let the actor pick it up from the global on first use):
// let url = Bundle.main.url(forResource: "simkl-trending-urls", withExtension: "json")!
// let data = try Data(contentsOf: url)
// let SIMKL_TRENDING_URLS = try JSONSerialization.jsonObject(with: data) as! [String: Any]
// SimklTrendingClient(config:, trending: SIMKL_TRENDING_URLS) — fatalErrors if neither global nor arg is set.
// === CONFIG — tune to match your app ===
struct SimklTrendingConfig {
// What to load
var supportedTypes: [String] = ["movies", "tv", "anime", "dvd"] // movies / tv / anime / dvd — drop ones your app doesn't surface
var timeframes: [String] = ["today"] // today / week / month (ignored for dvd)
var size: Int = 100 // 100 or 500 — use 500 if you render derived catalogs (see "Build your own catalogs" below)
// HTTP — replace with YOUR values
var clientId: String = "YOUR_CLIENT_ID"
var appName: String = "my-app-name"
var appVersion: String = "1.0"
var userAgent: String = "my-app-name/1.0"
var requestTimeout: TimeInterval = 8
var minIntervalMs: Int = 250 // process-wide floor between consecutive requests
var fetchMaxAttempts: Int = 3
var fetchBaseBackoff: TimeInterval = 1
var fetchMaxBackoff: TimeInterval = 16
var retryStatuses: Set = [408, 425, 429, 500, 502, 503, 504] // never retry other 4xx — they're permanent
// Refresh
var regenRetrySeconds: [Int] = [300, 900, 1800] // 5 / 15 / 30 min after a 304
var jitterPercent: Double = 0.05 // ±5%
// Persistent cache — set persistFile to nil to disable
var persistFile: URL? = FileManager.default
.urls(for: .cachesDirectory, in: .userDomainMask).first?.appendingPathComponent("simkl-trending-cache.json")
var persistMaxAgeSeconds: TimeInterval = 7 * 86_400
// Catalogs — the rows on your home screen. Nil = auto-build one per supportedType.
var catalogs: [SimklTrendingCatalogConfig]? = nil
}
struct SimklTrendingCatalogConfig {
let id: String // unique row key
let source: String // 'movies' | 'tv' | 'anime' | 'dvd'
var timeframe: String? = nil // defaults to cfg.timeframes[0]
var title: String? = nil // defaults to JSON's pre-composed title
var transform: (([[String: Any]]) -> [[String: Any]])? = nil
}
struct SimklTrendingCatalog {
let id: String
let title: String
let items: [[String: Any]]
}
struct SimklTrendingError: Error {
let message: String
let status: Int?
let retryAfter: TimeInterval?
let permanent: Bool
var localizedDescription: String { message }
}
struct SimklTrendingEntry: Codable {
let title: String, url: String
let refreshSeconds: Int
let types: [String]
}
actor SimklTrendingClient {
private var cfg: SimklTrendingConfig
private let trending: [String: Any]
private var cache: [String: _SimklCached] = [:]
private var inflight: [String: Task] = [:]
private var subs: [String: [UUID: ([[String: Any]]) -> Void]] = [:]
private var loops: [Task] = []
private var stopped: Bool = false
private var lastRequestAt: Date? = nil // for minIntervalMs floor
struct _SimklCached { var itemsByType: [String: [[String: Any]]]; var lastModified: String?; var types: [String]; var fetchedAt: Date }
struct SimklTrendingFetchResult { let itemsByType: [String: [[String: Any]]]?; let lastModified: String?; let notModified: Bool }
private var defaultCatalogs: [[String: Any]]? = nil
init(config: SimklTrendingConfig = SimklTrendingConfig(), trending: [String: Any]? = nil) {
self.cfg = config
let data = trending ?? (SIMKL_TRENDING_URLS as [String: Any])
// Accept both shapes: { urls: {...}, catalogs: [...] } from JSON OR the legacy unwrapped { combined, movies, ... }.
self.trending = (data["urls"] != nil) ? data : ["urls": data]
self.defaultCatalogs = data["catalogs"] as? [[String: Any]]
}
// === Public API ===
func start() async {
restorePersisted()
for t in subs.keys { notify(type: t) } // paint immediately from restored cache
for entry in plan() {
let task = Task { await loop(entry) }
loops.append(task)
}
}
func stop() {
stopped = true
for task in loops { task.cancel() }
for (_, t) in inflight { t.cancel() }
loops.removeAll(); inflight.removeAll()
}
// Wipe in-memory + persistent cache and notify subscribers (so UI repaints empty).
// Call client.tick() after this to immediately re-fetch.
func clearCache() {
cache.removeAll()
if let url = cfg.persistFile { try? FileManager.default.removeItem(at: url) }
for (t, _) in subs { notify(type: t) }
}
func get(_ type: String) -> [[String: Any]] {
for v in cache.values { if let arr = v.itemsByType[type] { return arr } }
return []
}
func subscribe(_ type: String, _ cb: @escaping ([[String: Any]]) -> Void) -> UUID {
let id = UUID()
subs[type, default: [:]][id] = cb
let items = get(type); if !items.isEmpty { cb(items) } // immediate paint from cache
return id
}
func unsubscribe(_ type: String, _ id: UUID) { subs[type]?.removeValue(forKey: id) }
// === Catalogs — rows on the home screen ===
func catalogs() -> [SimklTrendingCatalog] {
return catalogConfigs().map { buildCatalog($0) }
}
func subscribeCatalogs(_ cb: @escaping ([SimklTrendingCatalog]) -> Void) -> [UUID] {
let sources = Set(catalogConfigs().map { $0.source })
var ids: [UUID] = []
for source in sources {
ids.append(subscribe(source) { _ in cb(self.catalogs()) })
}
if sources.allSatisfy({ get($0).isEmpty }) { cb(catalogs()) } // empty cache — skeleton paint
return ids
}
private func catalogConfigs() -> [SimklTrendingCatalogConfig] {
if let cs = cfg.catalogs { return cs } // explicit override wins
if let raw = defaultCatalogs { // JSON's catalogs[] (recommended)
return raw.compactMap { d in
guard let id = d["id"] as? String, let src = d["source"] as? String else { return nil }
return SimklTrendingCatalogConfig(id: id, source: src,
timeframe: d["timeframe"] as? String, title: d["title"] as? String, transform: nil)
}
}
let tf0 = cfg.timeframes.first ?? "today" // last resort: auto-build
return cfg.supportedTypes.map { t in
t == "dvd"
? SimklTrendingCatalogConfig(id: "dvd", source: "dvd")
: SimklTrendingCatalogConfig(id: "\(t)_\(tf0)", source: t, timeframe: tf0)
}
}
private func fileTitleFor(_ source: String, _ timeframe: String?) -> String {
let sizeKey = "top_\(cfg.size)"
let urls = trending["urls"] as? [String: Any] ?? [:]
let file: [String: Any]?
if source == "dvd" {
file = (urls["dvd"] as? [String: Any])?[sizeKey] as? [String: Any]
} else {
let bucket = (urls[source] as? [String: Any])?[timeframe ?? "today"] as? [String: Any]
file = bucket?[sizeKey] as? [String: Any]
}
return (file?["title"] as? String) ?? "Trending \(source) on Simkl"
}
private func buildCatalog(_ c: SimklTrendingCatalogConfig) -> SimklTrendingCatalog {
let items = get(c.source)
let out = c.transform?(items) ?? items
let title = c.title ?? fileTitleFor(c.source, c.timeframe ?? cfg.timeframes.first)
return SimklTrendingCatalog(id: c.id, title: title, items: out)
}
// Call from BGTaskScheduler for a single revalidate-and-persist pass.
func tick() async {
restorePersisted()
for entry in plan() {
let lm = cache[entry.url]?.lastModified
if let r = try? await fetchDedupd(entry, lastModified: lm), !r.notModified, r.itemsByType != nil {
storeAndNotify(entry: entry, result: r)
}
}
persist()
}
// === Internals ===
private func requiredQuery() -> String {
let an = cfg.appName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? cfg.appName
let av = cfg.appVersion.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? cfg.appVersion
return "client_id=\(cfg.clientId)&app-name=\(an)&app-version=\(av)"
}
private func plan() -> [SimklTrendingEntry] {
precondition(cfg.size == 100 || cfg.size == 500, "size must be 100 or 500")
let key = "top_\(cfg.size)"
let urls = trending["urls"] as! [String: Any]
let tvma = cfg.supportedTypes.filter { ["movies","tv","anime"].contains($0) }
let useCombined = tvma.count >= 2 // 1 round-trip < 2-3 per timeframe
func make(_ raw: Any, types: [String]) -> SimklTrendingEntry {
let d = raw as! [String: Any]
return SimklTrendingEntry(title: d["title"] as! String, url: d["url"] as! String,
refreshSeconds: d["refresh_seconds"] as! Int, types: types)
}
var out: [SimklTrendingEntry] = []
if !tvma.isEmpty {
for tf in cfg.timeframes {
let bucket = (useCombined ? urls["combined"] : urls[tvma[0]]) as! [String: Any]
out.append(make((bucket[tf] as! [String: Any])[key]!, types: tvma))
}
}
if cfg.supportedTypes.contains("dvd") {
out.append(make((urls["dvd"] as! [String: Any])[key]!, types: ["dvd"]))
}
return out
}
// In-flight dedup: actor-serialized lookup of inflight tasks.
private func fetchDedupd(_ entry: SimklTrendingEntry, lastModified: String?) async throws -> SimklTrendingFetchResult {
let sep = entry.url.contains("?") ? "&" : "?"
let url = entry.url + sep + requiredQuery()
if let t = inflight[url] { return try await t.value }
let task = Task { try await self.runFetch(url: url, entry: entry, lastModified: lastModified) }
inflight[url] = task
defer { inflight[url] = nil }
return try await task.value
}
// Process-wide rate-limit floor (so N entries refreshing at once don't fire N simultaneous requests).
private func throttle() async {
if let last = lastRequestAt {
let gapMs = Date().timeIntervalSince(last) * 1_000
let floor = Double(cfg.minIntervalMs)
if gapMs < floor { try? await Task.sleep(nanoseconds: UInt64((floor - gapMs) * 1_000_000)) }
}
lastRequestAt = Date()
}
private static func parseRetryAfter(_ value: String?) -> TimeInterval? {
guard let v = value else { return nil }
if let s = TimeInterval(v) { return max(0, s) }
let f = DateFormatter(); f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"; f.locale = Locale(identifier: "en_US_POSIX")
if let d = f.date(from: v) { return max(0, d.timeIntervalSinceNow) }
return nil
}
// runFetch — throttle + retry with exponential backoff + Retry-After + permanent-error classify. Normalizes both response shapes.
private func runFetch(url: String, entry: SimklTrendingEntry, lastModified: String?) async throws -> SimklTrendingFetchResult {
var lastErr: Error?
for attempt in 0..= cutoff,
let ibt = v["itemsByType"] as? [String: [[String: Any]]],
let types = v["types"] as? [String] else { continue }
cache[k] = _SimklCached(itemsByType: ibt, lastModified: v["lastModified"] as? String,
types: types, fetchedAt: Date(timeIntervalSince1970: fa))
}
}
}
// === Run ===
Task {
let client = SimklTrendingClient()
_ = await client.subscribeCatalogs { rows in
for row in rows { print("\(row.title): \(row.items.count) items") }
}
await client.start()
}
// === Background scheduling (iOS) ===
// Register a BGAppRefreshTask in your AppDelegate. In the handler call `await client.tick()`
// to perform a single revalidate-and-persist pass — Task.sleep timers do not fire while the
// app is suspended, so BGTaskScheduler is what keeps the cache warm in the background.
```
```kotlin Kotlin (Android) theme={"theme":{"light":"github-light","dark":"vesper"}}
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.net.URLEncoder
import java.util.concurrent.TimeUnit
import kotlin.random.Random
import kotlin.math.pow
// === Load the URL catalog ===
// 1. Save the "Copy-paste JSON" block above as `simkl-trending-urls.json` in `app/src/main/assets/`.
// 2. Load it into a global `SIMKL_TRENDING_URLS: JSONObject` once at app startup
// (e.g. in your Application.onCreate or DI module):
// val SIMKL_TRENDING_URLS = JSONObject(
// context.assets.open("simkl-trending-urls.json").bufferedReader().use { it.readText() }
// )
// You can also skip the global and pass it directly: `SimklTrendingClient(cfg, trending = urls)`.
// The constructor throws if neither the global nor the `trending` arg is provided.
// === CONFIG — tune to match your app ===
data class SimklTrendingConfig(
// What to load
val supportedTypes: List = listOf("movies", "tv", "anime", "dvd"), // movies / tv / anime / dvd — drop ones your app doesn't surface
val timeframes: List = listOf("today"), // today / week / month (ignored for dvd)
val size: Int = 100, // 100 or 500 — use 500 if you render derived catalogs (see "Build your own catalogs" below)
// HTTP — replace with YOUR values
val clientId: String = "YOUR_CLIENT_ID",
val appName: String = "my-app-name",
val appVersion: String = "1.0",
val userAgent: String = "my-app-name/1.0",
val requestTimeoutMs: Long = 8_000,
val minIntervalMs: Long = 250, // process-wide floor between consecutive requests
val fetchMaxAttempts: Int = 3,
val fetchBaseBackoffMs: Long = 1_000,
val fetchMaxBackoffMs: Long = 16_000,
val retryStatuses: Set = setOf(408, 425, 429, 500, 502, 503, 504), // never retry other 4xx — they're permanent
// Refresh
val regenRetrySeconds: List = listOf(300, 900, 1_800), // 5 / 15 / 30 min after a 304
val jitterPercent: Double = 0.05, // ±5%
// Persistent cache — set persistFile to null to disable. In Android pass context.cacheDir.
val persistFile: File? = null, // e.g. File(context.cacheDir, "simkl-trending-cache.json")
val persistMaxAgeMs: Long = 7L * 86_400 * 1_000,
// Catalogs — the rows on your home screen. Null = auto-build one per supportedType.
val catalogs: List? = null,
)
data class SimklTrendingCatalogConfig(
val id: String,
val source: String, // "movies" | "tv" | "anime" | "dvd"
val timeframe: String? = null, // defaults to cfg.timeframes[0]
val title: String? = null, // defaults to JSON's pre-composed title
val transform: ((List) -> List)? = null,
)
data class SimklTrendingCatalog(val id: String, val title: String, val items: List)
data class SimklTrendingEntry(val title: String, val url: String, val refreshSeconds: Int, val types: List)
class SimklTrendingError(message: String, val status: Int? = null, val retryAfterMs: Long? = null, val permanent: Boolean = false) : RuntimeException(message)
class SimklTrendingClient(
private val cfg: SimklTrendingConfig = SimklTrendingConfig(),
data: JSONObject = SIMKL_TRENDING_URLS,
) {
// Accept both JSON shapes: { urls: {...}, catalogs: [...] } OR the legacy unwrapped { combined, movies, ... }.
private val trending: JSONObject = if (data.has("urls")) data else JSONObject().put("urls", data)
private val defaultCatalogs: JSONArray? = data.optJSONArray("catalogs")
private data class _SimklCached(val itemsByType: Map, val lastModified: String?, val types: List, val fetchedAt: Long)
data class SimklTrendingFetchResult(val itemsByType: Map?, val lastModified: String?, val notModified: Boolean)
private val http = OkHttpClient.Builder()
.callTimeout(cfg.requestTimeoutMs, TimeUnit.MILLISECONDS).build()
private val cache = mutableMapOf()
private val inflight = mutableMapOf>()
private val subs = mutableMapOf) -> Unit>>()
private var subIdSeq = 0
private val mu = Mutex()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var lastRequestAt = 0L // for minIntervalMs floor
// === Public API ===
suspend fun start() {
restorePersisted()
mu.withLock { subs.keys.toList() }.forEach { notify(it) } // paint immediately from restored cache
plan().forEach { entry -> scope.launch { loop(entry) } }
}
fun stop() { scope.cancel() }
/** Wipe in-memory + persistent cache and notify subscribers (UI repaints empty). Call client.tick() after to re-fetch. */
suspend fun clearCache() {
val snapshot = mu.withLock {
cache.clear()
subs.mapValues { it.value.values.toList() }
}
cfg.persistFile?.takeIf { it.exists() }?.delete()
snapshot.forEach { (_, cbs) -> cbs.forEach { it(emptyList()) } }
}
suspend fun get(type: String): List = mu.withLock {
for (v in cache.values) v.itemsByType[type]?.let { return@withLock listOf(it) }
emptyList()
}
suspend fun subscribe(type: String, cb: (List) -> Unit): Int {
val id = mu.withLock {
val i = ++subIdSeq
subs.getOrPut(type) { mutableMapOf() }[i] = cb
i
}
val items = get(type); if (items.isNotEmpty()) cb(items) // immediate paint from cache
return id
}
suspend fun unsubscribe(type: String, id: Int) { mu.withLock { subs[type]?.remove(id) } }
// === Catalogs — rows on the home screen ===
suspend fun catalogs(): List = catalogConfigs().map { buildCatalog(it) }
suspend fun subscribeCatalogs(cb: (List) -> Unit): List {
val sources = catalogConfigs().map { it.source }.toSet()
val ids = mutableListOf()
for (s in sources) {
ids.add(subscribe(s) { _ -> scope.launch { cb(catalogs()) } })
}
if (sources.all { get(it).isEmpty() }) cb(catalogs()) // empty cache — skeleton paint
return ids
}
private fun catalogConfigs(): List {
cfg.catalogs?.let { return it } // explicit override wins
defaultCatalogs?.let { arr -> // JSON's catalogs[]
return (0 until arr.length()).mapNotNull { i ->
val o = arr.optJSONObject(i) ?: return@mapNotNull null
val id = o.optString("id"); val src = o.optString("source")
if (id.isEmpty() || src.isEmpty()) null
else SimklTrendingCatalogConfig(id = id, source = src,
timeframe = if (o.isNull("timeframe")) null else o.optString("timeframe"),
title = if (o.isNull("title")) null else o.optString("title"),
transform = null)
}
}
val tf0 = cfg.timeframes.firstOrNull() ?: "today" // last resort: auto-build
return cfg.supportedTypes.map { t ->
if (t == "dvd") SimklTrendingCatalogConfig(id = "dvd", source = "dvd")
else SimklTrendingCatalogConfig(id = "${t}_${tf0}", source = t, timeframe = tf0)
}
}
private fun fileTitleFor(source: String, timeframe: String?): String {
val sizeKey = "top_${cfg.size}"
val urls = trending.optJSONObject("urls") ?: return "Trending $source on Simkl"
val file = if (source == "dvd") urls.optJSONObject("dvd")?.optJSONObject(sizeKey)
else urls.optJSONObject(source)?.optJSONObject(timeframe ?: "today")?.optJSONObject(sizeKey)
return file?.optString("title") ?: "Trending $source on Simkl"
}
private suspend fun buildCatalog(c: SimklTrendingCatalogConfig): SimklTrendingCatalog {
val items = get(c.source)
val out = c.transform?.invoke(items) ?: items
val title = c.title ?: fileTitleFor(c.source, c.timeframe ?: cfg.timeframes.firstOrNull())
return SimklTrendingCatalog(c.id, title, out)
}
// Call from a WorkManager PeriodicWorker for a single revalidate-and-persist pass.
suspend fun tick() {
restorePersisted()
plan().forEach { entry ->
val lm = mu.withLock { cache[entry.url]?.lastModified }
runCatching { fetchDedupd(entry, lm) }.getOrNull()?.let { r ->
if (!r.notModified && r.itemsByType != null) storeAndNotify(entry, r)
}
}
persist()
}
// === Internals ===
private fun requiredQuery(): String =
"client_id=${cfg.clientId}" +
"&app-name=${URLEncoder.encode(cfg.appName, "UTF-8")}" +
"&app-version=${URLEncoder.encode(cfg.appVersion, "UTF-8")}"
private fun plan(): List {
require(cfg.size == 100 || cfg.size == 500) { "size must be 100 or 500" }
val key = "top_${cfg.size}"
val urls = trending.getJSONObject("urls")
val tvma = cfg.supportedTypes.filter { it in listOf("movies", "tv", "anime") }
val useCombined = tvma.size >= 2 // 1 round-trip < 2-3 per timeframe
fun make(o: JSONObject, types: List) =
SimklTrendingEntry(o.getString("title"), o.getString("url"), o.getInt("refresh_seconds"), types)
val out = mutableListOf()
if (tvma.isNotEmpty()) cfg.timeframes.forEach { tf ->
val bucket = if (useCombined) urls.getJSONObject("combined") else urls.getJSONObject(tvma[0])
out.add(make(bucket.getJSONObject(tf).getJSONObject(key), tvma))
}
if ("dvd" in cfg.supportedTypes)
out.add(make(urls.getJSONObject("dvd").getJSONObject(key), listOf("dvd")))
return out
}
// In-flight dedup: only one outstanding network call per URL.
private suspend fun fetchDedupd(entry: SimklTrendingEntry, lastModified: String?): SimklTrendingFetchResult {
val sep = if (entry.url.contains('?')) "&" else "?"
val url = entry.url + sep + requiredQuery()
val existing = mu.withLock { inflight[url] }
if (existing != null) return existing.await()
val task = scope.async { runFetch(url, entry, lastModified) }
mu.withLock { inflight[url] = task }
try { return task.await() } finally { mu.withLock { inflight.remove(url) } }
}
// Process-wide rate-limit floor (so N entries refreshing at once don't fire N simultaneous requests).
private suspend fun throttle() {
val gap = System.currentTimeMillis() - lastRequestAt
if (gap < cfg.minIntervalMs) delay(cfg.minIntervalMs - gap)
lastRequestAt = System.currentTimeMillis()
}
private fun parseRetryAfter(value: String?): Long? {
if (value == null) return null
value.toLongOrNull()?.let { return maxOf(0L, it * 1000L) }
return runCatching {
val fmt = java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME
val dt = java.time.ZonedDateTime.parse(value, fmt)
maxOf(0L, dt.toInstant().toEpochMilli() - System.currentTimeMillis())
}.getOrNull()
}
// runFetch — throttle + retry with exponential backoff + Retry-After + permanent-error classify. Normalizes both response shapes.
private suspend fun runFetch(url: String, entry: SimklTrendingEntry, lastModified: String?): SimklTrendingFetchResult {
var lastErr: Exception? = null
for (attempt in 0 until cfg.fetchMaxAttempts) {
throttle()
try {
val req = Request.Builder().url(url)
.header("User-Agent", cfg.userAgent)
.header("Accept", "application/json")
.apply { lastModified?.let { header("If-Modified-Since", it) } }.build()
http.newCall(req).execute().use { res ->
if (res.code == 304) return SimklTrendingFetchResult(null, lastModified, notModified = true)
if (!res.isSuccessful) {
val retryAfter = parseRetryAfter(res.header("Retry-After"))
val permanent = res.code !in cfg.retryStatuses
throw SimklTrendingError("${res.code} ${res.message} — ${entry.title}",
status = res.code, retryAfterMs = retryAfter, permanent = permanent)
}
val body = res.body!!.string().trim()
val ibt = mutableMapOf()
if (body.startsWith("[")) ibt[entry.types[0]] = JSONArray(body) // per-type / dvd → array
else { // combined → object, filter to wanted types
val obj = JSONObject(body)
entry.types.forEach { ibt[it] = obj.optJSONArray(it) ?: JSONArray() }
}
return SimklTrendingFetchResult(ibt, res.header("Last-Modified"), notModified = false)
}
} catch (e: CancellationException) { throw e }
catch (e: SimklTrendingError) {
if (e.permanent) throw e // don't waste backoff on permanent errors
lastErr = e
val honored = e.retryAfterMs
val backoff = honored ?: (cfg.fetchBaseBackoffMs * 2.0.pow(attempt.toDouble())).toLong().coerceAtMost(cfg.fetchMaxBackoffMs)
val jitter = (backoff * (Random.nextDouble() * 0.2 - 0.1)).toLong() // ±10% jitter
delay(maxOf(0L, backoff + jitter))
}
catch (e: Exception) {
lastErr = e
val backoff = (cfg.fetchBaseBackoffMs * 2.0.pow(attempt.toDouble())).toLong().coerceAtMost(cfg.fetchMaxBackoffMs)
delay(backoff)
}
}
throw lastErr ?: SimklTrendingError("fetch failed")
}
private suspend fun loop(entry: SimklTrendingEntry) {
var lastModified = mu.withLock { cache[entry.url]?.lastModified }
var retry = 0
while (scope.isActive) {
val r = try { fetchDedupd(entry, lastModified) }
catch (e: CancellationException) { return }
catch (e: SimklTrendingError) {
if (e.permanent) { println("${entry.title}: ${e.message} (permanent, halting loop)"); return }
delay(30_000L); continue
}
catch (e: Exception) { delay(30_000L); continue }
val wait = if (r.notModified) {
cfg.regenRetrySeconds[minOf(retry, cfg.regenRetrySeconds.lastIndex)].also { retry++ }
} else {
storeAndNotify(entry, r)
lastModified = r.lastModified; retry = 0
persist()
(entry.refreshSeconds * (1.0 + (Random.nextDouble() - 0.5) * 2 * cfg.jitterPercent)).toInt()
}
delay(wait * 1_000L)
}
}
private suspend fun storeAndNotify(entry: SimklTrendingEntry, r: SimklTrendingFetchResult) {
mu.withLock {
cache[entry.url] = _SimklCached(r.itemsByType ?: emptyMap(), r.lastModified, entry.types, System.currentTimeMillis())
}
entry.types.forEach { notify(it) }
}
private suspend fun notify(type: String) {
val items = get(type)
val cbs = mu.withLock { subs[type]?.values?.toList().orEmpty() }
cbs.forEach { it(items) }
}
private suspend fun persist() {
val f = cfg.persistFile ?: return
val dump = JSONObject()
mu.withLock {
for ((url, v) in cache) {
val o = JSONObject()
val ibt = JSONObject(); v.itemsByType.forEach { (k, arr) -> ibt.put(k, arr) }
o.put("itemsByType", ibt)
o.put("lastModified", v.lastModified ?: JSONObject.NULL)
o.put("types", JSONArray(v.types)); o.put("fetchedAt", v.fetchedAt)
dump.put(url, o)
}
}
runCatching {
val tmp = File(f.parentFile, f.name + ".tmp")
tmp.writeText(dump.toString()); tmp.renameTo(f)
}
}
private suspend fun restorePersisted() {
val f = cfg.persistFile ?: return
if (!f.exists()) return
runCatching {
val raw = JSONObject(f.readText())
val cutoff = System.currentTimeMillis() - cfg.persistMaxAgeMs
mu.withLock {
raw.keys().forEach { url ->
val o = raw.getJSONObject(url)
val fa = o.optLong("fetchedAt", 0L); if (fa < cutoff) return@forEach
val ibtO = o.getJSONObject("itemsByType")
val ibt = mutableMapOf()
ibtO.keys().forEach { t -> ibt[t] = ibtO.getJSONArray(t) }
val types = (0 until o.getJSONArray("types").length()).map { o.getJSONArray("types").getString(it) }
cache[url] = _SimklCached(ibt, if (o.isNull("lastModified")) null else o.getString("lastModified"), types, fa)
}
}
}
}
}
// === Run ===
runBlocking {
val client = SimklTrendingClient()
client.subscribeCatalogs { rows ->
for (row in rows) println("${row.title}: ${row.items.size} items")
}
client.start()
}
// === Background scheduling (Android) ===
// In-process coroutines stop when the app process is killed. Schedule a
// WorkManager.PeriodicWorkRequest whose Worker calls `client.tick()` to keep the cache warm
// in the background. Use cfg.persistFile = File(context.cacheDir, "simkl-trending-cache.json")
// so the next foreground launch can paint the UI from disk instantly.
```
```dart Dart (Flutter) theme={"theme":{"light":"github-light","dark":"vesper"}}
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:http/http.dart' as http;
// import 'package:path_provider/path_provider.dart'; // for persistFile in Flutter — see Run block
// === Load the URL catalog ===
// 1. Save the "Copy-paste JSON" block above as `assets/simkl-trending-urls.json`, then declare
// it under `flutter: assets:` in pubspec.yaml.
// 2. Load it into a global `SIMKL_TRENDING_URLS` once at app startup (after WidgetsFlutterBinding.ensureInitialized):
// final SIMKL_TRENDING_URLS = jsonDecode(
// await rootBundle.loadString('assets/simkl-trending-urls.json')
// ) as Map;
// For pure-Dart (server / CLI / tests), use:
// final SIMKL_TRENDING_URLS = jsonDecode(await File('simkl-trending-urls.json').readAsString()) as Map;
// You can also skip the global and pass it explicitly: `SimklTrendingClient(trending: urlsMap)`.
// === CONFIG — tune to match your app ===
class SimklTrendingConfig {
// What to load
final List supportedTypes; // movies / tv / anime / dvd
final List timeframes; // today / week / month (ignored for dvd)
final int size; // 100 or 500 — use 500 if you render derived catalogs (see "Build your own catalogs" below)
// HTTP — replace with YOUR values
final String clientId, appName, appVersion, userAgent;
final Duration requestTimeout;
final Duration minInterval; // process-wide floor between consecutive requests
final int fetchMaxAttempts;
final Duration fetchBaseBackoff, fetchMaxBackoff;
final Set retryStatuses; // never retry other 4xx — they're permanent
// Refresh
final List regenRetrySeconds; // 5 / 15 / 30 min after a 304
final double jitterPercent; // ±5%
// Persistent cache — set persistFile to null to disable.
final File? persistFile;
final Duration persistMaxAge;
// Catalogs — the rows on your home screen. Null = auto-build one per supportedType.
final List? catalogs;
const SimklTrendingConfig({
this.supportedTypes = const ['movies', 'tv', 'anime', 'dvd'],
this.timeframes = const ['today'],
this.size = 100,
this.clientId = 'YOUR_CLIENT_ID',
this.appName = 'my-app-name',
this.appVersion = '1.0',
this.userAgent = 'my-app-name/1.0',
this.requestTimeout = const Duration(seconds: 8),
this.minInterval = const Duration(milliseconds: 250),
this.fetchMaxAttempts = 3,
this.fetchBaseBackoff = const Duration(seconds: 1),
this.fetchMaxBackoff = const Duration(seconds: 16),
this.retryStatuses = const {408, 425, 429, 500, 502, 503, 504},
this.regenRetrySeconds = const [300, 900, 1800],
this.jitterPercent = 0.05,
this.persistFile,
this.persistMaxAge = const Duration(days: 7),
this.catalogs,
});
}
class SimklTrendingCatalogConfig {
final String id;
final String source; // 'movies' | 'tv' | 'anime' | 'dvd'
final String? timeframe; // defaults to cfg.timeframes.first
final String? title; // defaults to JSON's pre-composed title
final List Function(List)? transform;
SimklTrendingCatalogConfig({required this.id, required this.source, this.timeframe, this.title, this.transform});
}
class SimklTrendingCatalog {
final String id, title;
final List items;
SimklTrendingCatalog(this.id, this.title, this.items);
}
class SimklTrendingError implements Exception {
final String message;
final int? status;
final Duration? retryAfter;
final bool permanent;
SimklTrendingError(this.message, {this.status, this.retryAfter, this.permanent = false});
@override String toString() => 'SimklTrendingError($status): $message';
}
class SimklTrendingEntry {
final String title, url;
final int refreshSeconds;
final List types;
SimklTrendingEntry(this.title, this.url, this.refreshSeconds, this.types);
}
class _SimklCached {
Map> itemsByType; String? lastModified; List types; int fetchedAt;
_SimklCached(this.itemsByType, this.lastModified, this.types, this.fetchedAt);
}
class SimklTrendingFetchResult {
final Map>? itemsByType;
final String? lastModified;
final bool notModified;
SimklTrendingFetchResult({this.itemsByType, this.lastModified, this.notModified = false});
}
class SimklTrendingClient {
final SimklTrendingConfig cfg;
final Map trending;
final _cache = {};
final _inflight = >{};
final _subs = )>>{};
final _client = http.Client();
int _subIdSeq = 0;
bool _stopped = false;
int _lastRequestAt = 0; // for minInterval floor
final _loops = >[];
final List? defaultCatalogs;
// Accept both JSON shapes: { urls: {...}, catalogs: [...] } OR the legacy unwrapped { combined, movies, ... }.
factory SimklTrendingClient({SimklTrendingConfig? config, Map? trending}) {
final raw = trending ?? (SIMKL_TRENDING_URLS as Map);
final urlsMap = raw['urls'] != null ? raw : { 'urls': raw };
return SimklTrendingClient._internal(config ?? const SimklTrendingConfig(),
urlsMap as Map,
raw['catalogs'] as List?);
}
SimklTrendingClient._internal(this.cfg, this.trending, this.defaultCatalogs);
// === Public API ===
Future start() async {
await _restorePersisted();
for (final t in _subs.keys.toList()) _notify(t); // paint immediately from restored cache
for (final entry in _plan()) _loops.add(_loop(entry));
}
void stop() { _stopped = true; _client.close(); }
/// Wipe in-memory + persistent cache and notify subscribers (UI repaints empty). Call client.tick() after to re-fetch.
Future clearCache() async {
_cache.clear();
final f = cfg.persistFile;
if (f != null && await f.exists()) { try { await f.delete(); } catch (_) {/* ignore */} }
for (final t in _subs.keys.toList()) _notify(t);
}
List get(String type) {
for (final v in _cache.values) if (v.itemsByType.containsKey(type)) return v.itemsByType[type]!;
return const [];
}
int subscribe(String type, void Function(List) cb) {
final id = ++_subIdSeq;
(_subs[type] ??= {})[id] = cb;
final items = get(type); if (items.isNotEmpty) cb(items); // immediate paint from cache
return id;
}
void unsubscribe(String type, int id) { _subs[type]?.remove(id); }
// === Catalogs — rows on the home screen ===
List catalogs() => _catalogConfigs().map(_buildCatalog).toList();
List subscribeCatalogs(void Function(List) cb) {
final sources = _catalogConfigs().map((c) => c.source).toSet();
final ids = [];
for (final s in sources) {
ids.add(subscribe(s, (_) => cb(catalogs())));
}
if (sources.every((s) => get(s).isEmpty)) cb(catalogs()); // empty cache — skeleton paint
return ids;
}
List _catalogConfigs() {
if (cfg.catalogs != null) return cfg.catalogs!; // explicit override wins
if (defaultCatalogs != null) { // JSON's catalogs[]
return defaultCatalogs!.whereType
**Node 22+, Deno, Bun, React Native, Cloudflare Workers, and other fetch-based JS runtimes** — the **JavaScript** tab above (the SDK file) is universal: save it verbatim as `simkl-trending-client.js`, then wire it up from your app entry point in \~6 lines. The Browser tab right above it is the HTML+DOM wrapper you'd skip in a headless runtime; here's the non-browser equivalent:
```js app.mjs (or index.js / worker.js / App.tsx) theme={"theme":{"light":"github-light","dark":"vesper"}}
// Pick the loader your runtime prefers:
import data from './simkl-trending-urls.json' with { type: 'json' }; // Node 22+ ESM
// const data = require('./simkl-trending-urls.json'); // Node CommonJS
// const data = await (await fetch('/simkl-trending-urls.json')).json(); // Deno / Bun / RN / CF Workers
import { SimklTrendingClient } from './simkl-trending-client.js';
const client = new SimklTrendingClient(
{ clientId: 'YOUR_CLIENT_ID', appName: 'my-app', appVersion: '1.0' },
data,
);
client.subscribeCatalogs(rows => rows.forEach(r => console.log(`${r.title}: ${r.items.length} items`)));
await client.start();
// process.on('SIGINT', () => { client.stop(); process.exit(0); }); // long-running servers
```
Bundle `simkl-trending-urls.json` with your app (or fetch + cache it once at startup) so the catalog is in scope when `SimklTrendingClient` runs. For Cloudflare Workers / Lambda / GitHub Actions etc., call `await client.tick()` from your scheduler instead of `client.start()` — see the "Background scheduling" comment at the bottom of the JavaScript tab.
**What you still need to do per-platform.** The `SimklTrendingClient` samples above wire up everything that's host-agnostic — auto-pick combined, persistent cache, conditional revalidation, regen-lag backoff, jitter, retry with exponential backoff + timeout, in-flight dedup, cancellation, and subscribe/get. Two things are left to you because they depend on your platform:
* **Background scheduling that survives app lifecycle.** In-process timers stop firing when an iOS app is backgrounded or after Android process death. Hook your platform's periodic task system to `client.tick()` — `BGTaskScheduler` (iOS), `WorkManager.PeriodicWorkRequest` (Android), the `workmanager` package (Flutter), or `node-cron` / Cloudflare Cron / AWS EventBridge for server-side runtimes. Each tab's trailing comment shows the wireup.
* **Attribution back-link.** Websites must link the rendered section title to the matching `simkl.com//best-*/most-watched/` page — see [Attribution required](#attribution-required) and [Linking back](#linking-back-websites-only).
If you want stronger typing on the per-item shape, the [Field reference](#field-reference) below shows the JSON, ready to bind to a TypeScript interface, Swift `Codable`, Kotlin `@Serializable`, or Dart `json_serializable`.
### Catalogs — the rows on your home screen
Think of a TV-style discovery UI: rows of poster carousels stacked vertically, each row with a title at the top-left. The SDK exposes that as `client.catalogs()` → `[{id, title, items}, ...]` and `client.subscribeCatalogs(cb)` — available in **all 5 languages** above. Each row has a stable `id`, an attribution-compliant `title`, and an `items` array. You iterate and render.
**Three places rows can come from**, in priority order:
1. **`cfg.catalogs` in your SDK config** — explicit, wins if set. Use this to mix trending + derived rows with custom `transform` closures (see below).
2. **`catalogs[]` in the [Copy-paste JSON](#copy-paste-json)** above — the default list of rows. **Already populated** with one row per type (`movies_today`, `tv_today`, `anime_today`, `dvd`). Add, remove, or reorder entries in the JSON to change defaults across your whole app — no code change.
3. **Auto-built fallback** — if neither of the above is set, the SDK builds one row per `supportedType` × `timeframes[0]` from your CONFIG.
So with the JSON catalogs already populated, `new SimklTrendingClient()` immediately gives you 4 ready-to-render rows — no extra config needed. Override only when you want custom rows.
**Adding derived rows in code** — when you want filter/sort logic that's not in the JSON (e.g. "Hidden Gems"), pass `cfg.catalogs` with a `transform` function on each entry:
```js Node / Browser theme={"theme":{"light":"github-light","dark":"vesper"}}
import { SimklTrendingClient, SimklTrendingRecipes as R } from './simkl-trending-client.js';
const client = new SimklTrendingClient({
supportedTypes: ['movies', 'tv', 'anime'],
size: 500, // recommended once you derive rows
catalogs: [
{ id: 'movies_today', source: 'movies' }, // trending (JSON title)
{ id: 'tv_today', source: 'tv' },
{ id: 'anime_today', source: 'anime' },
{ id: 'movies_anticipated', source: 'movies', title: 'Most Watchlisted Movies on Simkl', transform: R.mostWatchlisted },
{ id: 'hidden_gems_anime', source: 'anime', title: 'Hidden Gem Anime on Simkl', transform: items => R.hiddenGems(items, { minRank: 3000, source: 'mal' }) },
{ id: 'best_of_hbo', source: 'tv', title: 'Best of HBO — on Simkl', transform: items => R.bestOfNetwork(items, 'HBO') },
],
});
client.subscribeCatalogs(rows => { for (const r of rows) renderRow(r.title, r.items); });
await client.start();
```
```python Python theme={"theme":{"light":"github-light","dark":"vesper"}}
client = SimklTrendingClient({
'supported_types': ['movies', 'tv', 'anime'],
'size': 500,
'catalogs': [
{'id': 'movies_today', 'source': 'movies'}, # trending (JSON title)
{'id': 'tv_today', 'source': 'tv'},
{'id': 'anime_today', 'source': 'anime'},
{'id': 'movies_anticipated', 'source': 'movies', 'title': 'Most Watchlisted Movies on Simkl',
'transform': lambda items: sorted(items, key=lambda i: -i.get('plan_to_watch', 0))},
{'id': 'hidden_gems_anime', 'source': 'anime', 'title': 'Hidden Gem Anime on Simkl',
'transform': lambda items: [i for i in items
if i.get('ratings',{}).get('simkl',{}).get('rating',0) >= 8.5
and i.get('watched',999) < 100]},
{'id': 'best_of_hbo', 'source': 'tv', 'title': 'Best of HBO — on Simkl',
'transform': lambda items: sorted([i for i in items if i.get('network') == 'HBO'
and i.get('status') == 'ended'
and i.get('ratings',{}).get('simkl',{}).get('rating',0) >= 8],
key=lambda i: -i['ratings']['simkl']['rating'])},
],
})
client.subscribe_catalogs(lambda rows: [renderRow(r['title'], r['items']) for r in rows])
client.start()
```
```swift Swift (iOS) theme={"theme":{"light":"github-light","dark":"vesper"}}
let cfg = SimklTrendingConfig(
supportedTypes: ["movies", "tv", "anime"],
size: 500,
catalogs: [
.init(id: "movies_today", source: "movies"), // trending (JSON title)
.init(id: "tv_today", source: "tv"),
.init(id: "anime_today", source: "anime"),
.init(id: "movies_anticipated", source: "movies", title: "Most Watchlisted Movies on Simkl",
transform: { items in items.sorted {
($0["plan_to_watch"] as? Int ?? 0) > ($1["plan_to_watch"] as? Int ?? 0)
} }),
.init(id: "hidden_gems_anime", source: "anime", title: "Hidden Gem Anime on Simkl",
transform: { items in items.filter {
let r = (($0["ratings"] as? [String: Any])?["simkl"] as? [String: Any])?["rating"] as? Double ?? 0
return r >= 8.5 && (($0["watched"] as? Int) ?? 999) < 100
} }),
.init(id: "best_of_hbo", source: "tv", title: "Best of HBO — on Simkl",
transform: { items in items.filter {
($0["network"] as? String) == "HBO" && ($0["status"] as? String) == "ended"
} }),
]
)
Task {
let client = SimklTrendingClient(config: cfg)
_ = await client.subscribeCatalogs { rows in
for r in rows { renderRow(r.title, r.items) }
}
await client.start()
}
```
```kotlin Kotlin (Android) theme={"theme":{"light":"github-light","dark":"vesper"}}
val cfg = SimklTrendingConfig(
supportedTypes = listOf("movies", "tv", "anime"),
size = 500,
catalogs = listOf(
SimklTrendingCatalogConfig(id = "movies_today", source = "movies"), // trending (JSON title)
SimklTrendingCatalogConfig(id = "tv_today", source = "tv"),
SimklTrendingCatalogConfig(id = "anime_today", source = "anime"),
SimklTrendingCatalogConfig(id = "movies_anticipated", source = "movies", title = "Most Watchlisted Movies on Simkl",
transform = { items -> items.sortedByDescending { it.optInt("plan_to_watch") } }),
SimklTrendingCatalogConfig(id = "hidden_gems_anime", source = "anime", title = "Hidden Gem Anime on Simkl",
transform = { items -> items.filter {
it.optJSONObject("ratings")?.optJSONObject("simkl")?.optDouble("rating", 0.0) ?: 0.0 >= 8.5
&& it.optInt("watched", 999) < 100
} }),
SimklTrendingCatalogConfig(id = "best_of_hbo", source = "tv", title = "Best of HBO — on Simkl",
transform = { items -> items.filter {
it.optString("network") == "HBO" && it.optString("status") == "ended"
} }),
),
)
runBlocking {
val client = SimklTrendingClient(cfg)
client.subscribeCatalogs { rows -> for (r in rows) renderRow(r.title, r.items) }
client.start()
}
```
```dart Dart (Flutter) theme={"theme":{"light":"github-light","dark":"vesper"}}
final cfg = SimklTrendingConfig(
supportedTypes: ['movies', 'tv', 'anime'],
size: 500,
catalogs: [
SimklTrendingCatalogConfig(id: 'movies_today', source: 'movies'), // trending (JSON title)
SimklTrendingCatalogConfig(id: 'tv_today', source: 'tv'),
SimklTrendingCatalogConfig(id: 'anime_today', source: 'anime'),
SimklTrendingCatalogConfig(id: 'movies_anticipated', source: 'movies', title: 'Most Watchlisted Movies on Simkl',
transform: (items) => [...items]..sort((a, b) => (b['plan_to_watch'] ?? 0).compareTo(a['plan_to_watch'] ?? 0))),
SimklTrendingCatalogConfig(id: 'hidden_gems_anime', source: 'anime', title: 'Hidden Gem Anime on Simkl',
transform: (items) => items.where((i) =>
((i['ratings']?['simkl']?['rating'] as num?) ?? 0) >= 8.5 && (i['watched'] as int? ?? 999) < 100).toList()),
SimklTrendingCatalogConfig(id: 'best_of_hbo', source: 'tv', title: 'Best of HBO — on Simkl',
transform: (items) => items.where((i) => i['network'] == 'HBO' && i['status'] == 'ended').toList()),
],
);
final client = SimklTrendingClient(config: cfg);
client.subscribeCatalogs((rows) { for (final r in rows) renderRow(r.title, r.items); });
await client.start();
```
Need the rows synchronously (e.g. inside a render function)? `client.catalogs()` returns the resolved list on demand. Same `{id, title, items}` shape in every language.
**Available recipe transforms** (drop into `transform:` to derive a row). Each has tunable opts as the 2nd arg — `hiddenGems(items, { minRating, minVotes, minRank, source })`, etc. Dial them to your taste without forking.
| Group | Recipe names |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Sorts** | `mostWatchlisted`, `highestRated`, `stickiest`, `newest`, `bestBoxOffice` |
| **Filters** | `byGenre`, `byGenreAll`, `byCountry`, `byNetwork`, `byStatus`, `byAnimeType`, `withTrailer`, `quickWatches`, `marathonWorthy`, `miniSeries`, `justReleased`, `inTheatersNow`, `recentlyOnDvd`, `ongoing`, `recentlyEnded`, `justPremiered`, `byYear`, `byDecade`, `currentYear`, `lastYear` |
| **Composed** | `hiddenGems`, `criticallyAcclaimed`, `crossPlatformConsensus`, `massiveAnticipation`, `bestOfNetwork`, `bestOfGenre`, `bestOfDecade` |
| **Groupings** | `groupByGenre`, `groupByCountry`, `groupByNetwork`, `groupByDecade` (these return an *object* of buckets — convert to rows with `Object.entries(R.groupByGenre(items)).map(([genre, items]) => ({ id: 'genre_' + genre, title: 'Trending ' + genre + ' on Simkl', items }))`) |
**Use `size: 500` once you render derived rows.** The top-100 file gives \~10-30 items per derived row at best (e.g. `hiddenGems` off `today_100` movies typically yields 10-15 candidates). The top-500 file gives \~50-200 per row — enough to scroll horizontally. Same fetch schedule, \~3-5x larger file.
**Adding / reordering rows costs nothing.** Catalog rows are resolved at read time off the cached items. Changing `cfg.catalogs` and recomputing produces a new row list in microseconds. The SDK refreshes the underlying cache on its own cadence (\~1h for `today`, 1d for `week`/`month`), independent of how many rows you render.
**Yields on the live top-500 (May 2026 snapshot)** — pick recipes to match your row-fill target: `hiddenGems` 67 movies / 156 TV / 204 anime · `criticallyAcclaimed` (default 8.5+ rating, 1k+ votes) 15 movies · `marathonWorthy` 75 TV · `crossPlatformConsensus` 66 movies · `bestBoxOffice` 449 movies parseable · `quickWatches` 38 movies under 90 min · `byNetwork(tv, 'Netflix')` 64 · `byCountry(anime, 'jp')` 361. Defaults are intentionally conservative — relax them if you want fewer empty rows.
### Catalog ideas — recipe logic at a glance
The `SimklTrendingRecipes` namespace is shipped Node-only. If you're porting to another language (the SDK class scaffolding above already covers Python / Swift / Kotlin / Dart), here's the logic for each recipe — drop it into your `transform:` / `client.get(type)` pipeline and you have the row.
| Catalog row | Logic |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Most Watchlisted** | sort `plan_to_watch` desc — items that appear on the most user watchlists. The name "Most Anticipated" misled here: this fires for already-released titles too (huge backlog appeal — *Game of Thrones*, *Avatar*, *One Piece* all rank high). Use this for the universal "what's on people's minds" row. |
| **Highest Rated (Simkl)** | sort `ratings.simkl.rating` desc, gate on `ratings.simkl.votes >= 500` to drop noise |
| **Highest Rated (IMDb / MAL)** | same, on `ratings.imdb.rating` (movies/TV) or `ratings.mal.rating` (anime) |
| **Stickiest** | sort `parseFloat(drop_rate)` asc — viewers who started, finished, and didn't bail |
| **Newest releases** | sort by parsed `release_date` desc — `release_date` is `MM/DD/YYYY` (e.g. `03/26/2026`), which `new Date(...)` / Python `datetime.strptime("%m/%d/%Y")` / Swift `DateFormatter("MM/dd/yyyy")` all accept |
| **Box-office hits** *(movies)* | extract `$NNNM` from `metadata`, sort desc |
| **Quick watches** *(movies)* | parse `runtime` to minutes, filter `≤ 90`, sort by rating |
| **Marathon-worthy** *(TV/anime)* | filter `status === 'ended'` AND `20 ≤ total_episodes ≤ 100`, sort by rating |
| **Mini-series** *(TV/anime)* | filter `total_episodes ≤ 6` |
| **By genre / country / network / status / anime type** | `items.filter(...)` on the matching field |
| **In theaters now** *(movies)* | filter `theater` date within last 30 days |
| **Just released on DVD** *(movies)* | filter `dvd_date` within last 14-30 days |
| **Just premiered** *(TV/anime/movies)* | filter `status === 'premiere'` — flags items whose first episode JUST aired (typically within \~30 days). Not an "upcoming premieres" filter; the data has no field for that. |
| **By year (e.g. *Movies of 2026*)** | parse the 4-digit year out of `release_date` (any format), filter equality |
| **By decade (e.g. *Best of the 90s*)** | parse year, filter `decade ≤ y < decade + 10` |
| **Current year / last year** | same as **By year**, comparing against `new Date().getFullYear()` (or your language's equivalent) — re-evaluated per call so the row stays current without re-shipping the JSON |
| **Best of decade** | combine **By decade** with `bestOfNetwork`'s shape — rating-sort + `votes >= 100` floor inside the decade window |
| **Has trailer** | `items.filter(i => i.trailer)` — for trailer-first carousel UIs |
| **Hidden Gems** | filter `rank > 2000` AND `rating >= 7.5` AND `votes >= 500`, then sort by rating desc — well-loved titles OUTSIDE Simkl's all-time top 2000 by popularity. **Don't filter by `watched`** on trending feeds: that field is last-24h viewers, not lifetime, so a 150-cap surfaces mainstream titles like *The Office* or *Interstellar*. Rank is the right popularity gate; mainstream lives in rank 50-500, gems live in 2000+. Tune `minRank` up (3000, 5000) for stricter obscurity. |
| **Critically acclaimed, widely seen** | `rating >= 8.5` AND `votes >= 1000` — high bar on both axes |
| **Massive anticipation** | `plan_to_watch / (watched + 1) >= 300` — pre-release / newly-announced (median ratio is \~200; 300 is upper quartile) |
| **Cross-platform consensus** | Simkl rating ≥ 8 AND IMDb (or MAL) rating ≥ 8 — both crowds agree |
| **Best of ** | filter network match + `status === 'ended'`, sort by rating |
| **Grouped by genre / country / network / decade** | bucket items into `{ "Action": [...], "Drama": [...] }` for N rows from one file |
**Tuning a recipe per catalog row.** Every recipe accepts an options object as its second arg. In the JSON, set them via `recipe_opts`. For example, to make `tv_hidden_gems` stricter — only titles outside Simkl's top 3000 with rating ≥ 8 instead of the default 2000/7.5 — override the knobs:
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"id": "tv_hidden_gems",
"source": "tv",
"title": "Hidden Gem TV on Simkl",
"recipe": "hiddenGems",
"recipe_opts": { "minRank": 3000, "minRating": 8 }
}
```
Same pattern works for any recipe — `bestOfDecade` with `{ "decade": 1980 }`, `bestOfNetwork` with `{ "network": "AMC" }`, `highestRated` with `{ "minVotes": 2000 }`, etc. Check today's live data before bumping thresholds aggressively though; over-strict opts can produce empty rows on smaller trending feeds.
**Attribution still applies to derived rows.** A row labeled *"Most Watchlisted Movies"* or *"Hidden Gems"* still needs to include "Simkl" or "Most Watched" to satisfy the [attribution requirement](#attribution-required) — e.g. *"Most Watchlisted on Simkl"*, *"Simkl Hidden Gems"*, *"Trending in Japan — Powered by Simkl"*.
## What's in each item
Per-title arrays (`movies`, `tv`, `anime`) all share the same base shape, with a few type-specific fields.
```json Sample item theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"title": "The Drama",
"alt_titles": [
{ "name": "La Comedia", "lang": 8, "type": "official" },
{ "name": "Дорама", "lang": 22, "type": "official" }
],
"url": "/movie/2533163/the-drama",
"poster": "19/19702715dfee84bd7c",
"fanart": "19/19840547ea18093f08",
"ids": {
"simkl_id": 2533163,
"slug": "the-drama",
"imdb": "tt33071426",
"tmdb": "1148540"
},
"release_date": "03/26/2026",
"rank": 3311,
"drop_rate": "1.6%",
"watched": 96,
"plan_to_watch": 820,
"ratings": {
"simkl": { "rating": 7.37, "votes": 136 },
"imdb": { "rating": 7.4, "votes": 42928 }
},
"country": "us",
"original_language": "en",
"runtime": "1h 45m",
"status": "ended",
"genres": ["Action", "Comedy", "Drama", "Romance"],
"trailer": "0ZDzsH3XGFA",
"overview": "A happily engaged couple is put to the test...",
"metadata": "March 26, 2026 • Budget $28M • Box office $121M",
"dvd_date": "05/05/2026",
"theater": "03/26/2026"
}
```
### Field reference
Display title.
Localized and alternate titles. Always present on every catalog, but often an **empty array** — check length before reading. Popular titles can carry dozens of entries (60+ on the most widely-released films), so slice before rendering.
Each entry has three keys:
* **`name`** *(string)* — the alternate title.
* **`type`** *(string)* — `official` for a localized release title, `original` for the title in the production's own language.
* **`lang`** *(integer)* — Simkl-internal language code. **This is not an ISO 639 code** and there is currently no public mapping table, so treat it as an opaque grouping key rather than something to render.
If you only need one display title per item, use `title` and ignore this field entirely.
Path on simkl.com (e.g. `/movie/2533163/the-drama`). Prepend `https://simkl.com` to deep-link.
Image path fragment. Combine with the prefixes in [Image conventions](/conventions/images) — for example `https://wsrv.nl/?url=https://simkl.in/posters/{poster}_m.webp&q=90`. When `null`, fall back to `https://simkl.in/poster_no_pic.png` (see [fallbacks](/conventions/images#fallback-when-images-are-missing)).
Image path fragment for fanart. Same prefixing rules as `poster`. When `null`, hide the fanart element — there's no dedicated fanart placeholder.
External and Simkl IDs. Always carries `simkl_id` + `slug`. `tmdb` is near-universal; `imdb` appears on TV / movies, `mal` / `anidb` / `anilist` / `kitsu` on anime. Additional third-party slug and ID variants (`letterslug`, `traktmslug`, `tvdbslug`, `trakttvslug`, `mdlslug`, `jwtv`, …) may appear on items with those platform links — the response is permissive, so read the keys you need and ignore the rest.
Simkl ID — primary key for `/movies/{id}`, `/tv/{id}`, `/anime/{id}`.URL-safe slug.IMDB ID, e.g. `tt0944947`.TMDB ID.TVDB ID (TV / anime).MyAnimeList ID (anime).AniDB ID (anime).AniList ID (anime).Kitsu ID (anime).
Simkl service rank for this media type.
Number of users who watched this title in the timeframe.
Number of users with this title on their Plan-to-Watch watchlist.
Percentage of users who started and dropped, formatted as a string (e.g. `"1.6%"`).
Aggregate ratings — Simkl and any external sources available for this title (IMDB for movies/TV, MAL for anime, etc.). Each entry is `{ rating: number, votes: number }`.
Original release date in `MM/DD/YYYY` format.
ISO-style country code (e.g. `us`, `jp`). **Nullable on anime** (\~28% of the anime feed); always populated on movies, TV, and DVD.
Lowercase two-letter language code for the production's original audio (`en`, `ja`, `zh`, `ko`). **Nullable on anime** (\~33% of the anime feed); always populated on movies, TV, and DVD. Distinct from `country`, which is the country of origin — a Japanese-language title can be `country: "us"` on a co-production.
Human-readable duration (e.g. `1h 45m`, `25m`).
One of `ended` (production wrapped), `ongoing` (TV/anime still airing new episodes), or `premiere` (very-recently-released, still in the rollout window). The wider Simkl catalog also uses `tba` for not-yet-dated titles, but those don't surface in trending.
Array of genre tags.
YouTube video ID.
Synopsis.
Pre-formatted human-readable summary line (release year, budget, box office, network, etc.).
**Movies only.** DVD release date (`MM/DD/YYYY`).
**Movies only.** Theatrical release date (`MM/DD/YYYY`).
**Anime only.** Romanized Japanese title (e.g. `Mushoku Tensei III: Isekai Ittara Honki Dasu`), for apps that display romaji alongside or instead of the English `title`. `null` on roughly a quarter of the anime feed — fall back to `title`.
**Anime only.** One of `tv`, `movie`, `special`, `ova`, `ona`, `music video`.
**TV / anime.** Episode count.
**TV / anime.** Broadcasting network.
## Without the SDK — raw URL and curl
The [`SimklTrendingClient`](#simkltrendingclient-sdk) above is the recommended way to consume trending data — it handles caching, conditional revalidation, retry, dedup, persistence, and the auto-pick combined endpoint logic. The patterns below are the bare-bones alternative for ad-hoc curl probes, server-side cron jobs that handle their own caching, or runtimes where you don't want to drop a class file.
### URL pattern
If you'd rather construct the URL in code:
```text theme={"theme":{"light":"github-light","dark":"vesper"}}
https://data.simkl.in/discover/trending/[/]_.json
```
* **``** — omit for **Combined** (movies + tv + anime in one response); use `movies`, `tv`, or `anime` for type-specific.
* **``** — `today`, `week`, or `month`.
* **``** — `100` or `500`.
**DVD releases** use a different prefix and no timeframe:
```text theme={"theme":{"light":"github-light","dark":"vesper"}}
https://data.simkl.in/discover/dvd/releases_.json
```
### Fetch it
```bash curl theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -H 'User-Agent: my-app-name/1.0' \
"https://data.simkl.in/discover/trending/today_100.json?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0"
```
```js JavaScript theme={"theme":{"light":"github-light","dark":"vesper"}}
const params = new URLSearchParams({
client_id: 'YOUR_CLIENT_ID',
'app-name': 'my-app-name',
'app-version': '1.0',
});
const r = await fetch(
`https://data.simkl.in/discover/trending/movies/today_100.json?${params}`,
{ headers: { 'User-Agent': 'my-app-name/1.0' } },
);
const items = await r.json();
console.log(items[0].title, items[0].watched);
```
```python Python theme={"theme":{"light":"github-light","dark":"vesper"}}
import requests
r = requests.get(
'https://data.simkl.in/discover/trending/movies/today_100.json',
params={
'client_id': 'YOUR_CLIENT_ID',
'app-name': 'my-app-name',
'app-version': '1.0',
},
headers={'User-Agent': 'my-app-name/1.0'},
)
items = r.json()
print(items[0]['title'], items[0]['watched'])
```
```go Go theme={"theme":{"light":"github-light","dark":"vesper"}}
package main
import (
"encoding/json"
"fmt"
"net/http"
)
type Item struct {
Title string `json:"title"`
Rank int `json:"rank"`
Watched int `json:"watched"`
}
func main() {
req, _ := http.NewRequest("GET",
"https://data.simkl.in/discover/trending/movies/today_100.json", nil)
q := req.URL.Query()
q.Set("client_id", "YOUR_CLIENT_ID")
q.Set("app-name", "my-app-name")
q.Set("app-version", "1.0")
req.URL.RawQuery = q.Encode()
req.Header.Set("User-Agent", "my-app-name/1.0")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var items []Item
json.NewDecoder(resp.Body).Decode(&items)
fmt.Println(items[0].Title, items[0].Watched)
}
```
**Always send a `User-Agent`** with your app name and version (e.g. `myapp/1.0`) to avoid accidental blocking.
# About TV
Source: https://api.simkl.org/api-reference/tv
Look up TV shows, browse what's airing or premiering, and pull episode lists.
The TV API returns metadata about TV shows in Simkl's catalog. None of these endpoints require an OAuth `token` — a `client_id` is enough.
The `status` field on a show can be `ended`, `tba`, or `airing`.
## Look up a show
Item-level lookups for when you already know which show you want. Both are Cloudflare-cached by Simkl ID — see [Rate limits → Parallel requests](/resources/rate-limits#parallel-requests-when-allowed).
`GET /tv/{id}` — full record (overview, network, runtime, country, certification, genres, status, first/last-aired dates, total episodes, airs schedule, ratings, posters, fanart, trailers, external IDs, user recommendations).
`GET /tv/episodes/{id}` — every season + episode for a show, with `aired` flags and airdates.
If you only have an external ID (IMDb, TMDB, TVDB), resolve it to a Simkl ID via [`GET /redirect`](/api-reference/redirect) first — header-only, Cloudflare-cached, and the canonical resolver.
## Browse & discover
Find shows by what's on now, what's coming, what's top-rated, or by genre / country / network / year.
`GET /tv/airing` — what's airing right now.
`GET /tv/premieres/{param}` — upcoming season and series premieres.
`GET /tv/best/{filter}` — top-rated shows by filter (year, all-time, etc.).
`GET /tv/genres/...` — browse by genre, country, network, year.
## Pre-built data files (no per-user cost)
Two static JSON resources on the CDN cover the most common "what's hot / what's airing" surfaces without paying any per-user request budget. Send the standard URL parameters and User-Agent; no `Authorization` token needed.
`https://data.simkl.in/trending/tv_today.json` + week / month variants, plus Top-100 and Top-500 versions. Refreshed daily. Drives "Most Watched" / "Trending Now" surfaces without polling.
`https://data.simkl.in/calendar/tv.json` + per-month archives at `/calendar/{YEAR}/{MONTH}/tv.json`. Updated every 6 hours. Far cheaper than polling `/tv/airing`.
# About Users
Source: https://api.simkl.org/api-reference/users
Read public user data, fetch recently-watched art, and update user settings.
Public user data is available via `GET` methods without an OAuth or PIN token — only a `client_id` is required. Endpoints that change user data (settings, stats writes) require an authenticated `token`.
| Endpoint | What it does |
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /users/recently-watched-background/{user_id}` | Returns the user's most-recently-watched show or movie with its title, large poster, and large fanart. Useful for wallpaper apps or "now watching" widgets. |
| `POST /users/{user_id}/stats` | Returns aggregate watch statistics for the user. |
| `POST /users/settings` | Update the authenticated user's settings. |
Cache images on the device using the image URL as the cache key. Image URLs are immutable — never re-download the same URL.
`GET /users/recently-watched-background/{user_id}`
`POST /users/{user_id}/stats`
`POST /users/settings`
# API rules
Source: https://api.simkl.org/api-rules
Read this before you ship. The short list of what's required, what's free, and when you need a commercial license.
### Help us keep the Simkl API at 100% uptime — optimise your app's requests
The limits below are generous and most apps never hit them. The ones that do are almost always parallelizing calls to endpoints that should be sequential, or polling [`/sync/all-items`](/api-reference/simkl/get-all-items) without gating behind [`/sync/activities`](/api-reference/simkl/get-activities). The patterns on this page keep your app fast **and** keep the API healthy for every other developer building on Simkl.
These are the rules of the road for using the Simkl API. They're short, they're real, and they exist for good reasons. Read once, agree, and ship.
By using the Simkl API, you also agree to the full [Simkl Terms](https://simkl.com/about/policies/terms/).
**Don't get your `client_id` suspended.** The Simkl API caps every app at **10 GET/sec and 1 POST/sec** per `client_id` and per user token — generous, and most apps never hit them. The ones that do are almost always either parallelizing calls to uncached endpoints, or polling [`GET /sync/all-items`](/api-reference/simkl/get-all-items) on a timer without first checking [`GET /sync/activities`](/api-reference/simkl/get-activities) for changes. Apps that sustain overage get their `client_id` suspended **without warning, no appeal** — we see the traffic pattern and turn the key off.
See [**Rate limits**](/resources/rate-limits) for the full caps, when parallel requests are allowed (only on Cloudflare-cached endpoints), and the sequential-by-default pattern. The two-phase sync model in [Rule 7 → Sync incrementally](#7-be-a-good-api-citizen) shows the correct loop in code.
## 1. Link back to Simkl
Wherever Simkl data appears in your app, link back to the Simkl page for **that specific item** — not just a generic homepage link. Better for users (they can dive into ratings, episodes, related titles, reviews), better for Simkl, and trivial to implement.
`https://simkl.com/movies/:simkl_id/:slug` — [example](https://simkl.com/movies/2059585/superman)
`https://simkl.com/tv/:simkl_id/:slug` — [example](https://simkl.com/tv/1076658/the-witcher)
`https://simkl.com/anime/:simkl_id/:slug` — [example](https://simkl.com/anime/38636/one-piece)
Both `ids.simkl` and `ids.slug` are returned on every [standard media object](/conventions/standard-media-objects), so the slug is **free** — you already have it in the response you just parsed.
**Always include the slug when you have it.** It's technically optional — bare numeric URLs like `simkl.com/movies/53536` still work — but Simkl has to run a database lookup and `301`-redirect to the slugged URL, costing your user an extra round-trip and Simkl an extra search query. The slug is right there in `ids.slug`; pass it through and everyone wins.
**The `simkl` ID is the only stable identifier — slugs are not unique.** Multiple titles can share the same slug (e.g. three different *Superman* movies live at `/movies/2059585/superman`, `/movies/260740/superman`, `/movies/951252/superman`). Always build links with the numeric ID first; the slug is purely a human-readable URL hint. Never key your cache or do a lookup by slug alone.
```js title="build-simkl-url.js" theme={"theme":{"light":"github-light","dark":"vesper"}}
function simklUrl(item) {
const section = { movie: 'movies', anime: 'anime' }[item.type] || 'tv';
return 'https://simkl.com/' + section + '/' +
(item.ids.simkl || item.ids.simkl_id) + '/' +
(item.ids.slug || '');
}
simklUrl(movieDetails); // → https://simkl.com/movies/1306562/project-hail-mary
simklUrl(tvDetails); // → https://simkl.com/tv/967226/the-boys
simklUrl(animeDetails); // → https://simkl.com/anime/1885096/tongari-boushi-no-atelier
// No slug? URL ends with /:
simklUrl({ type: 'movie', ids: { simkl: 53536 } });
// → https://simkl.com/movies/53536/
```
Notes on the moving parts:
* TV detail responses set `item.type = "show"` even though the URL section is `/tv/`. The map deliberately only lists `movie` and `anime`; anything else (including `"show"` and the empty `type` returned by `/search/*` and `/{movies|tv|anime}/trending`) falls through to `/tv/` — Simkl will 301 to the correct section if needed, but you should set `item.type` yourself when you already know it (e.g. on `/movies/trending` results) to skip that hop.
* The id key is either `ids.simkl` (detail endpoints, `/sync/*` responses, request bodies) or `ids.simkl_id` (`/search/*`, `/trending`, `/calendar`, `/genres`, `/best`, `/premieres`, episode-level detail). The same integer; the key name differs by endpoint family — `simkl || simkl_id` covers both. See [Standard media objects → Supported ID keys](/conventions/standard-media-objects#supported-id-keys).
**Don't have a Simkl ID yet?** If all you have is an external ID — IMDB, TMDB, TVDB, MAL, AniDB, etc. — drop a [`/redirect`](/api-reference/redirect) URL **straight into your ``**. No request, no parsing, no JSON — the browser follows the `301` for you and the user lands on the canonical Simkl page.
```html theme={"theme":{"light":"github-light","dark":"vesper"}}
View on Simkl
```
[Click to try it](https://api.simkl.com/redirect?to=simkl\&imdb=tt0944947\&client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) → lands on [`https://simkl.com/tv/17465/game-of-thrones`](https://simkl.com/tv/17465/game-of-thrones).
Six placements that count as proper attribution:
* **Detail page** — a "View on Simkl" button or `Source: Simkl` line under the cover art.
* **List / grid rows** — make the cover or title clickable directly to the item's Simkl page.
* **Hover cards / tooltips** — include a "More on Simkl →" link inside the popover.
* **Footer / About** — a one-time `Movie, TV and anime data from Simkl` line.
* **Trending sections** — the title must say "Simkl" (see [rule 6](#6-attribute-simkl-in-trending-ui)) **and** items should deep-link to their Simkl page.
* **Empty / loading states** — "Powered by Simkl" line linking to [simkl.com](https://simkl.com) when no specific item is in view yet.
For a one-line text widget or a watch ticker where you only have room for a single link, a visible link to [simkl.com](https://simkl.com) is the minimum.
* A `simkl.com` link buried in your privacy policy with no visible UI attribution.
* An unbranded `More info` that doesn't say "Simkl".
* Fetching Simkl data into your own database and dropping the link entirely.
**These are just illustrative samples — not visual requirements.** Match the colours, shape, type, and density to your own design system. The only attribution rules are the substantive ones above: Simkl is named, the brand mark is visible, and the link goes to the per-item page with the slug.
Two brand-mark assets are available — the **colored PNG** for full-colour brand badges and the **monochrome SVG** (transparent "S" cutout) for icons that recolour with their parent via CSS `filter`:
| Asset | URL |
| -------------- | ---------------------------------------------------------- |
| Colored PNG | `https://us.simkl.in/img_favicon/v2/favicon-192x192.png` |
| Monochrome SVG | `https://us.simkl.in/img_favicon/v2/safari-pinned-tab.svg` |
```html Minimal rating pill (PNG) theme={"theme":{"light":"github-light","dark":"vesper"}}
★7.8
```
```html Rating badge (PNG) theme={"theme":{"light":"github-light","dark":"vesper"}}
★7.8on Simkl→
```
```html Inline "More on Simkl" (PNG) theme={"theme":{"light":"github-light","dark":"vesper"}}
More on Simkl →
```
```html "Powered by Simkl" pill (SVG) theme={"theme":{"light":"github-light","dark":"vesper"}}
Powered by Simkl
```
Not sure how to implement this for your specific app? [DM us on Discord](https://discord.gg/MJsWNE4) or visit [support.simkl.com](https://support.simkl.com) — we'll help you find the right placement.
## 2. Tracker apps must offer Simkl sync
If your app or service is a Movie / TV / Anime / Manga **tracker** that already syncs with another tracker, you may use the Simkl API only if you also offer **Simkl login and sync** alongside.
In other words: the catalog and discovery endpoints are not a free metadata source for a competing tracker that doesn't integrate Simkl. If you're integrating Simkl as one of multiple supported services in a media app, you're good. If you're using Simkl's catalog without offering Simkl sync, [contact us first](/support).
## 3. Use TVDB / TMDB for raw metadata
If you need a generic metadata host, please use [TVDB](https://thetvdb.com) and [TMDB](https://themoviedb.org) APIs from their original sources. Simkl is built for **tracking and discovery**, not as a CDN for the world's metadata.
## 4. Keep your `client_secret` secret
`client_secret` is a credential — treat it like a password. Never commit it to a public repo, and never embed it in code that ships to a user's device (mobile apps, single-page apps, browser extensions, desktop binaries). On a server, store it in an environment variable; in CI, store it as an encrypted secret — every CI provider supports this natively. A leaked secret lets anyone authenticate as your app, eats into your rate-limit quota, and forces you to rotate from your [app settings](https://simkl.com/settings/developer/) and re-auth every user.
`client_id` is **not** a secret — it's a public identifier that appears in every API URL and is fine to include in client-side code. The thing to protect is `client_secret`.
For client-side apps that can't keep a secret, pick the flow that matches your platform — both work without `client_secret`:
* **Mobile, single-page app, browser extension, desktop binary** — use [Public PKCE](/api-reference/oauth-pkce). Same browser-based UX as the standard OAuth flow, with a one-time `code_verifier` instead of a shipped secret.
* **TV, console, smartwatch, CLI tool, media-server plugin** — use the [PIN flow](/api-reference/pin). Show a 5-character code, the user enters it on their phone.
## 5. Free for non-commercial use, and under \$150 / month
The Simkl API is **free** for:
* Non-commercial apps and personal projects.
* Commercial apps and services that generate **less than \$150 / month in revenue**.
If your app generates **\$150 / month or more**, you must obtain a **commercial license** before continuing to use the API. [Reach out via Discord](https://discord.gg/MJsWNE4) to discuss terms.
Running the API at scale costs us real money in DNS, routing, health checks, CDN, server hosting, failover, and ops. Commercial fees cover those costs proportional to your usage.
## 6. Attribute Simkl in trending UI
If you display [Trending data](/api-reference/trending), you must use a section title that names Simkl — for example, `Simkl Trending Today`, `Trending on Simkl This Week`, `Simkl Trending Movies`. Full rules and examples on the [Trending page](/api-reference/trending#linking-back-websites-only).
## 7. Be a good API citizen
These are the habits well-built apps share. They keep your app fast, keep your users happy, and keep your traffic well clear of the rate-limit and overage patterns that trip the suspension warning at the top of this page.
Most metadata never changes. Cache by URL on the device — minutes for user data, hours for catalogs, **forever** for [images](/conventions/images).
The [Trending](/api-reference/trending) and [Calendar](/api-reference/calendar) JSON files don't count against your `client_id` quota and are cached at the edge. Always prefer them over per-user catalog calls.
**Never call watchlist endpoints without first checking [`/sync/activities`](/api-reference/simkl/get-activities).** And never run unconditional background polling timers without active user interaction.
Use a two-phase pattern:
1. **Initial sync (one time per user)** — pull the full library, no `date_from` on this pass.
* **Multi-type apps:** call `/sync/all-items/shows`, `/sync/all-items/movies`, `/sync/all-items/anime` **sequentially** (not in parallel — back-to-back massive payloads spike your CPU and ours).
* **Single-type apps** (anime-only, movie-only, TV-only): call only the one type you care about.
2. **Continuous sync (every poll after that)** — call `/sync/activities` first; if every timestamp matches what you stored locally, skip. Otherwise:
* **Multi-type apps:** `/sync/all-items?date_from=YOUR_SAVED_TIMESTAMP` — pulls deltas across all types and statuses in one response.
* **Single-type apps:** `/sync/all-items/{your_type}?date_from=YOUR_SAVED_TIMESTAMP` — same delta semantics, scoped to one type so you don't transfer data you'll discard.
Save the new `activities.all` timestamp for next time.
Triggers — sync **on user-visible events**, not timers:
* **Mobile / TV apps** — app start, wake-from-background, pull-to-refresh. Throttle to once per 15–30 min.
* **Media servers (Plex, Kodi, Jellyfin)** — library-scan-completed events, or when a playback session ends.
Full walkthrough with code: [Sync guide](/guides/sync).
`POST /sync/history`, `POST /sync/history/remove`, `POST /sync/ratings`, and `POST /sync/add-to-list` all accept arrays. Send 50 items in one call, not 50 calls.
Identify your app: `myapp/1.0`. It helps us tell legitimate clients apart from bad actors and keeps you out of any accidental blocks.
On `429`, `500`, `502`, `503`: wait, retry, double the wait, cap at 60s, give up after 5 attempts. See [Errors](/conventions/errors).
## More
The full set of limit signals and how to handle them.
Every status code Simkl returns and what to do about it.
Discord, email, GitHub — pick your favorite.
# Authentication
Source: https://api.simkl.org/authentication
Three flows — OAuth 2.0 for server-side web apps, Public PKCE for mobile and SPAs, PIN for TVs and CLIs. Pick one and you're 5 minutes from your first authenticated call.
Every Simkl API call that touches a user's data needs a per-user `access_token`. Tokens are long-lived — the success response advertises `expires_in: 157680000` (5 years), and in practice tokens remain valid until the user revokes your app from [Connected Apps settings](https://simkl.com/settings/connected-apps/). Simkl gives you three ways to obtain a token. They're all variants of OAuth 2.0 from the user's perspective, but the integration story is very different. Pick the flow that matches the device your app runs on.
For **server-side web apps** that can keep a `client_secret`. The user logs in via their browser; your backend exchanges the `code` for a token. \~5 seconds end-to-end.
For **mobile, SPA, browser extensions, desktop binaries** — any client where you can't safely embed `client_secret`. Same browser-based UX, no secret required.
For **TVs, consoles, watches, CLIs, and media-server plugins** — anywhere typing a URL is hard. Show a 5-character code; the user enters it on their phone.
## At a glance
| | OAuth 2.0 | Public PKCE | PIN |
| ------------------------- | ----------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------- |
| **Best for** | Server-side web apps | Mobile, SPA, browser extensions, desktop binaries | TVs, consoles, watches, CLIs, plugins |
| **Time to token** | \~5 seconds | \~5 seconds | 30 seconds – 2 minutes |
| **Needs `client_secret`** | Yes | **No** — uses `code_verifier` + `code_challenge` | No |
| **Needs `redirect_uri`** | Yes (pre-registered, byte-for-byte) | Yes, OR omit if no redirect URI is registered (consent completes on simkl.com) | No |
## Get an API key first
All three flows require a `client_id`. [Create an app](https://simkl.com/settings/developer/) — free, no approval required. Confidential clients also receive a `client_secret`; public clients can ignore it.
**Never embed `client_secret` in client-side code.** Mobile apps, single-page apps, browser extensions, and desktop binaries should use [Public PKCE](/api-reference/oauth-pkce) or the [PIN flow](/api-reference/pin) — both work without a secret.
## Full walkthrough
The complete reference — platform recommendations, multi-language code samples, common pitfalls, token lifecycle — lives in the API Reference:
Per-platform recommendations (iOS, Android, web, desktop, TV, headless), step-by-step OAuth and PIN walkthroughs with curl/Swift/Kotlin/Node/Python samples, common pitfalls, and token lifecycle in one page.
## Endpoint reference
`GET /oauth/authorize` and `POST /oauth/token`.
PKCE flow ([RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636)) with per-platform recipes.
`GET /oauth/pin` and `GET /oauth/pin/{USER_CODE}`.
## Before you reach for an OAuth library
**Both content-types and both credential locations work.** Simkl's `POST /oauth/token` accepts:
* `Content-Type: application/x-www-form-urlencoded` (the RFC 6749 §3.2 default) **or** `Content-Type: application/json` — pick whichever your HTTP client prefers
* Client credentials in the request body (`client_id` + `client_secret` parameters) **or** in the `Authorization: Basic` header (RFC 6749 §2.3.1) — both paths are honored
That means **off-the-shelf OAuth libraries work out-of-the-box** with no custom encoding or auth-method config. The two equivalent ways to call the token endpoint:
```bash form-encoded (RFC 6749 §3.2 default) theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST https://api.simkl.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "User-Agent: my-app-name/1.0" \
--data-urlencode "client_id=YOUR_CLIENT_ID" \
--data-urlencode "client_secret=YOUR_CLIENT_SECRET" \
--data-urlencode "code=AUTHORIZATION_CODE" \
--data-urlencode "redirect_uri=YOUR_REDIRECT_URI" \
--data-urlencode "grant_type=authorization_code"
```
```bash JSON body theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST https://api.simkl.com/oauth/token \
-H "Content-Type: application/json" \
-H "User-Agent: my-app-name/1.0" \
-d '{
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"code": "AUTHORIZATION_CODE",
"redirect_uri": "YOUR_REDIRECT_URI",
"grant_type": "authorization_code"
}'
```
For library-specific examples (Python, Node, Java, Go, PHP), see [OAuth client libraries](/api-reference/oauth-libraries) — most are zero-config.
# Changelog
Source: https://api.simkl.org/changelog
What's new across the Simkl API and these docs.
**[Discord](https://discord.gg/MJsWNE4)** is where most API announcements and developer chat happen — join us for new endpoints, breaking changes, and integration help in real time.
For broader Simkl product news — apps, features, releases — follow [**@Simkl**](https://twitter.com/Simkl) on X.
Use the **filter** in the right rail to narrow this changelog by tag.
Breaking
**Calendar data files v2 — old files stop updating 1 February 2027**
A new version of the [calendar data files](/api-reference/calendar) is live under a `/v2/` path segment, for all three catalogs and for both the rolling and monthly files:
```
https://data.simkl.in/calendar/v2/tv.json
https://data.simkl.in/calendar/v2/anime.json
https://data.simkl.in/calendar/v2/movie_release.json
https://data.simkl.in/calendar/v2/{YEAR}/{MONTH}/{tv|anime|movie_release}.json
```
**Deadline:** the old files (same paths **without** `/v2/`) keep regenerating until **1 February 2027**. After that date they stop updating permanently and will serve increasingly stale data. Update your code before then.
What's different in v2:
* **Split payload.** Each file is now one object with two keys instead of a flat array — `calendar` (one entry per airing or release, chronological) and `metadata` (one record per distinct title, keyed by Simkl ID). A show airing 12 episodes this month appears 12 times in `calendar` but only once in `metadata`, so titles, posters, IDs, ratings, and genres are stored once rather than repeated on every airing. Join the two on `simkl_id`.
* **All timestamps are UTC.** Every `date` is now ISO 8601 with a trailing `Z`. The old files carried per-catalog offsets (`-05:00` for US TV, `+09:00` for anime), which forced clients to normalize before sorting or comparing.
* **Finale markers.** A new `finale_type` field flags mid-season (`1`), season (`2`), and series (`3`) finales so you can badge them, `null` otherwise.
* **Much richer metadata.** Each title now carries alternate titles, fanart, popularity rank, drop rate, watched and plan-to-watch counts, ratings from Simkl plus IMDb or MAL, country, original language, runtime, status, network, genres, and trailer — plus `title_romaji` and `anime_type` on anime, and `dvd_date` on movies.
The [Calendar page](/api-reference/calendar) has the full field reference and a worked "next 7 days" example in JavaScript and Python.
Docs
**Trending SDK with live preview**
A new [Trending SDK](/api-reference/trending) page shows how to combine filters and sorts on top of our cached top-500 trending feeds. Drop-in JS file (also ported to Python, Swift, Kotlin, and Dart) plus an embedded CodePen you can fork.
* **Two CDN fetches → full home screen** — combined movies + TV + anime in one round-trip, plus DVD releases.
* **55 default catalog rows** ready to render with attribution-correct titles — Trending Today, Most Watchlisted, Top Rated, Hidden Gems, Just Premiered, Movies This Year, Best of Netflix / HBO / Disney+ / Prime / Apple TV, Best Action / Drama / Sci-Fi movies, Best of the 2020s / 2010s / 2000s / 90s, Marathon-Worthy TV, Quick Watches, Anime Movies and OVAs, and more.
* **Tunable recipes** (`hiddenGems`, `bestOfNetwork`, `bestOfGenre`, `bestOfDecade`, `currentYear`, `justReleased`, `marathonWorthy`, ...) — override knobs per row via `recipe_opts` or supply a custom `transform` closure.
* **Persistent cache** (localStorage in browsers, file in Node), conditional 304 revalidation, retry with backoff, request dedup, reactive subscribe / get API. No build step, no framework.
* Works as-is in browsers, Node 22+, Deno, Bun, React Native, and Cloudflare Workers.
Scroll to **"Try it live"** on [the trending page](/api-reference/trending) for the embedded CodePen, or open the page to see the full recipe reference.
Major launch
**Rewatches via `?allow_rewatch=yes`**
`POST /sync/history` and `GET /sync/all-items` now track rewatches as separate sessions when the caller opts in with `?allow_rewatch=yes`. **Simkl Pro / VIP only.**
* **Write:** post episodes (or whole items) the user is rewatching. The server auto-detects rewatch intent, or you can force it with `is_rewatch: true`. Each session has its own `rewatch_id`, `rewatch_status` (`active` / `completed` / `closed`), and per-episode timestamps.
* **Read:** any item with saved sessions appears multiple times in the response — its normal entry plus one rewatch row per session, each carrying `is_rewatch: true` and the session fields above.
Walkthrough, state-machine rules, and ready-made code for simkl.com-style UI patterns in the new [**Rewatches guide**](/guides/rewatches). Quick mention also in the [Sync guide → Record a rewatch](/guides/sync#common-write-operations) and [Mark as watched → Record a rewatch](/guides/mark-as-watched#record-a-rewatch) accordions.
Major launch
**API Analytics: live per-app request log**
Every registered Simkl app now has a built-in Analytics view at [/debug/api-analytics](/debug/api-analytics) showing exactly what your code sent to `api.simkl.com`. Up to 24 hours of individual requests, with edge and origin status codes, latency, burst detection, and CSV export.
What you can see:
* **Per-request rows** with method, path, query string, edge HTTP status, origin HTTP status, origin latency, cache status, country, and User-Agent. Each row has a **Link** column that re-fires the same `GET` in a new tab so you can read the live response body.
* **Burst detector** that flags 3+ requests from the same IP in one second with a red `BURST` badge. Helps you catch retry loops and runaway timers before the auto-blocker fires.
* **Filters** with `equals`, `contains`, `starts with`, `ends with`, and `in` operators over every column. Common recipe: `Origin contains 4` to surface 4xx failures.
* **CSV export** of the current filtered view for sharing with support or attaching to bug reports.
* **One-click mask for sensitive params**: hit a button to redact `code`, `access_token`, `refresh_token`, `pin`, and similar values as `***` before screenshotting or sharing the view.
How to open it:
1. Open [simkl.com/settings/developer/](https://simkl.com/settings/developer/) while logged in.
2. Click your app's row.
3. Click **Debug** (top-right of the app card). The analytics view opens in a new tab.
Useful for diagnosing OAuth wrappers that quietly drop headers, sync loops missing `date_from`, silent 4xx errors your HTTP client never surfaces, and stale edge-cache hits.
Full reference at [API Analytics](/debug/api-analytics).
Docs
**Docs revamp**
[Apiary](https://simkl.docs.apiary.io/) is retired. Oracle is shutting it down in October, and Apiary has been going down for hours at a stretch lately on top of that, so we used the forcing function to redo everything from scratch. The docs now live at [api.simkl.org](https://api.simkl.org). Most of the content is new: what was supposed to be a content migration turned into a top-to-bottom audit when we found significant drift between the old docs and the actual API.
What's new:
* **Detailed workflow guides**: [Sync](/guides/sync), [Scrobble](/guides/scrobble), [Search](/guides/search), [Anime](/guides/anime), [Deep linking](/guides/deep-linking), [Mark as watched](/guides/mark-as-watched), and the new [Rewatches](/guides/rewatches) guide.
* **All three auth flows properly walked through** at [Authentication](/authentication): OAuth 2.0 for server-side web apps, Public PKCE for mobile, SPAs, browser extensions and desktop binaries, and PIN flow for TVs, consoles, CLIs, and media-server plugins.
* **Conventions pages** for the cross-cutting topics Apiary never covered: [Headers](/conventions/headers), [Pagination](/conventions/pagination), [Dates](/conventions/dates), [Watchlist statuses](/conventions/list-statuses), [Null values](/conventions/null-values), [Images](/conventions/images), [Standard media objects](/conventions/standard-media-objects), [Extended info](/conventions/extended-info), and [CORS](/conventions/cors).
* **Flat [Errors and status codes](/conventions/errors)** page grouped by HTTP status with cause / fix / example for each.
* **Interactive try-it-now playground** on all 48 endpoints across 14 categories. Drop in your token, hit Send, get a real response back.
* **Copy-paste examples in 16 languages**: curl, Python, JS/TS, Kotlin, Swift, Java, Go, C#, Ruby, PowerShell, C/C++, and more.
* **LLM-friendly**: every page can be copied as raw markdown or opened straight in Claude / ChatGPT / Cursor / VSCode. Full-site [`llms.txt`](/llms.txt) and [`llms-full.txt`](/llms-full.txt) are published.
* **Full [OpenAPI 3.1 spec](/openapi.json)**: every endpoint and every response shape verified against the live API. Point your SDK generator at it.
* **One-page [endpoint index](/all-endpoints)** with all 48 endpoints across 14 categories at a glance.
* **Default three-column layout site-wide** (left sidebar, content, right-rail TOC) so every page has navigable section anchors.
Breaking
**`app-name` and `app-version` are now required**
Every API request must now include `app-name` and `app-version` URL parameters alongside `client_id`, plus a descriptive `User-Agent` header. This lets us help debug issues faster and prevents accidental blocks.
```
/endpoint?client_id=CLIENT_ID&app-name=my-app-name&app-version=1.0
```
See the updated [Quickstart](/quickstart) and [Headers and conventions](/conventions/headers).
Docs
**Renamed in the docs: "List" → "Watchlist"**
Simkl's website introduced **Custom Lists** — user-created collections of titles. To avoid confusion with the existing 5-status bucket system (Watching, Plan to Watch, Hold, Dropped, Completed), the API docs now refer to that bucket system as the **Watchlist** instead of "List".
**Nothing changed at the API level.** The endpoint URL `/sync/add-to-list` is unchanged, request/response shapes are unchanged. Only the docs use the term "Watchlist" now — e.g., the endpoint is referred to as **"Add to Watchlist"** in the sidebar and prose.
A dedicated Custom Lists API may ship in a future release.
New feature
**New: Pre-built trending JSON files**
We've published trending data as static JSON on `data.simkl.in` — no API key required (User-Agent and attribution still required). Useful for "What's hot right now" surfaces without paying any per-user request budget.
* **Top 100** and **Top 500** lists
* **Today**, **This Week**, and **This Month** timeframes
* **Combined**, **Movies**, **TV Shows**, and **Anime** categories
* **Latest popular DVD releases** list
* Full title info with multiple IDs
See the full file index in [Trending data files](/api-reference/trending).
New feature
**Manual "fire-and-forget" tracking**
New endpoint: [`POST /scrobble/checkin`](/api-reference/simkl/scrobble-checkin). Send one request when the user starts watching; Simkl handles the "watching now" bar, runtime tracking, and auto-completion when runtime elapses. One call, then nothing else — useful when you don't have real player events to drive [`/start`](/api-reference/simkl/scrobble-start) / [`/pause`](/api-reference/simkl/scrobble-pause) / [`/stop`](/api-reference/simkl/scrobble-stop).
| | `/scrobble/checkin` (manual) | `/scrobble/start` / `/pause` / `/stop` (real-time) |
| ----------------- | ----------------------------------- | -------------------------------------------------- |
| **Purpose** | Simple "watching now" status | Active player tracking |
| **When to call** | Once, when the user starts watching | On real player events (start, pause, stop, resume) |
| **Auto-complete** | Yes (marks watched on expiry) | No — requires explicit `/stop` |
| **`progress`** | Optional | Required |
Use cases: cinema apps, live-TV trackers, social "watching now" status updates. See [Scrobble guide](/guides/scrobble).
Improvement
**PIN poll response — two distinct shapes**
The `/oauth/pin/{USER_CODE}` polling response now returns one of two explicit JSON shapes (instead of a single shape with mixed semantics): `{ "result": "KO", "message": "Authorization pending" }` while the user hasn't entered the code yet, and `{ "result": "OK", "access_token": "..." }` once they approve. Simpler client-side state machine — branch on `result`.
See [PIN authentication](/api-reference/pin) for the response schemas.
Major launch
**Major: real-time scrobble + cross-device playback**
Two big additions for media-player integrations:
* **Scrobble API** — [`POST /scrobble/start`](/api-reference/simkl/scrobble-start), [`/pause`](/api-reference/simkl/scrobble-pause), and [`/stop`](/api-reference/simkl/scrobble-stop) for real-time playback tracking with progress percentages. See the [Scrobble guide](/guides/scrobble).
* **Playback API** — [`GET /sync/playback/{type}`](/api-reference/simkl/get-playback-sessions) and [`DELETE /sync/playback/{id}`](/api-reference/simkl/delete-playback) to read and clean up paused sessions across devices.
* [`GET /sync/activities`](/api-reference/simkl/get-activities) now includes a `playback` timestamp per type, so you know when to refetch playback state.
* New web UI: [Playback progress manager](https://simkl.com/my/history/playback-progress-manager/) — users can now manage their saved sessions in-product.
Use case: a user pauses an episode at 40% on the living-room TV, switches to their iPad in the bedroom, and the iPad app picks up exactly where they left off.
Note: only **one** playback is stored per show/movie. Starting a new episode replaces any previous paused playback for that title.
New feature
**Monthly archive endpoints**
The [Calendar JSON](/api-reference/calendar) now includes per-month archives for the last 12 months at the URL pattern `https://data.simkl.in/calendar/{YEAR}/{MONTH}/{tv|anime|movie_release}.json`. The current month regenerates every 6 hours; previous months once a day.
Docs
**Recommended image hosting: wsrv.nl**
We now recommend serving Simkl image paths through [wsrv.nl](https://wsrv.nl), a free image proxy that caches, resizes, and converts on the fly. This minimizes load on Simkl's image servers and gives you free transformations like WebP conversion.
See [Images](/conventions/images) for the URL pattern and size codes. Examples append `&q=90` for origin-equivalent quality (wsrv's default `&q=80` reduces filesize and quality).
Improvement
**`runtime` (minutes) on shows and movies**
`/sync/all-items` responses now include a `runtime` integer field on show and movie items — minutes per episode for shows, total runtime for movies. Useful for "time to finish", watch-time stats, and pacing surfaces.
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"runtime": 40
}
```
Improvement
**`simkl_type` and `anime_type` in `/sync/history` response**
The per-item `response` object inside `added.statuses` now includes `simkl_type` (`movie` / `tv` / `anime`) and `anime_type` (`tv` / `special` / `ova` / `movie` / `music video` / `ona`).
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
"response": {
"status": "completed",
"simkl_type": "anime",
"anime_type": "movie"
}
```
Useful when you're sending titles by TMDB ID and need to know how Simkl classified the item — e.g., a TMDB movie that Simkl resolved as an anime movie. Also handy for clients that store `simkl_type` locally so they can later reconcile deletions across types.
Improvement
**Complete a show in one call**
Two additions to `POST /sync/history`:
* **`status: "completed"`** — pass on a show item to set the watchlist status directly. If the show is still airing, Simkl keeps it in **Watching** instead and reflects the resolved status in the response.
* **`use_tvdb_anime_seasons: true`** — pair with `status: "completed"` on anime to auto-mark every season watched, using TVDB/TMDB seasonal numbering. Lets clients that index against TVDB skip the AniDB season-mapping step.
The response now returns a `statuses` array showing the resolved per-item status:
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"added": {
"movies": 0,
"shows": 1,
"episodes": 0,
"statuses": [
{
"request": { "ids": { "simkl": 39687 }, "status": "completed" },
"response": { "status": "watching" }
}
]
}
}
```
See [`POST /sync/history`](/api-reference/simkl/add-to-history).
# CORS
Source: https://api.simkl.org/conventions/cors
Cross-origin request support on api.simkl.com.
The Simkl API supports **Cross-Origin Resource Sharing (CORS)** on the `api.simkl.com` domain — you can call it directly from a browser using `fetch` or `XMLHttpRequest`.
```js theme={"theme":{"light":"github-light","dark":"vesper"}}
const res = await fetch(
'https://api.simkl.com/tv/17465?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0',
{
headers: {
'Content-Type': 'application/json',
'User-Agent': 'my-app-name/1.0',
},
},
);
```
**Don't ship `client_secret` in browser code.** If you need authenticated calls from a browser, use the [PIN flow](/api-reference/auth#how-the-pin-flow-works) (no secret) or proxy calls through your own server.
## Useful reading
* [CORS W3C working draft](http://www.w3.org/TR/cors)
* [HTML5 Security: Cross-Origin Request Security](http://code.google.com/p/html5security/wiki/CrossOriginRequestSecurity)
# Dates and timezones
Source: https://api.simkl.org/conventions/dates
How Simkl returns dates and what to do in your app.
All API dates are returned in **ISO 8601** format with the **`Z` (UTC / GMT)** timezone marker:
```
2015-03-15T15:30:11Z
```
Some legacy endpoints still return dates in `GMT-05:00` (New York) — this is being phased out, but for now treat the timezone field as authoritative rather than assuming UTC.
Convert to the user's local timezone in your app — don't assume the server's locale.
```js Node theme={"theme":{"light":"github-light","dark":"vesper"}}
const utc = new Date('2015-03-15T15:30:11Z');
const local = utc.toLocaleString(); // user's locale
```
```python Python theme={"theme":{"light":"github-light","dark":"vesper"}}
from datetime import datetime, timezone
utc = datetime.fromisoformat('2015-03-15T15:30:11+00:00')
local = utc.astimezone() # system local timezone
```
## Per-endpoint format matrix
Different endpoints use different date formats depending on whether
the field is a user activity timestamp, a broadcast schedule, or a
catalog release date. Use the per-field reference below to know what
to parse for each one.
**The four formats you'll meet:**
| Format | Example | When |
| --------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **`ISO_UTC_Z`** | `2026-05-13T16:08:00Z` | Default for user-generated timestamps (activities, watchlist, playback, ratings, profile). Convert to the user's profile timezone for display. |
| **`ISO_WITH_OFFSET`** | `2010-10-31T21:00:00-05:00` | Broadcast schedules — the offset is the **originating network's** timezone (US shows are `-05:00`, anime are `+09:00`). Use it as authoritative; don't assume UTC. |
| **`DATE_ONLY`** | `2010-07-15` | Catalog release dates (year-month-day, no time). |
| **`YEAR_ONLY`** | `2010` | Integer year-of-release on every catalog item. |
Plus the `1970-01-01T00:00:01Z` placeholder for *"watched, date unknown"* — see [the section below](#very-long-time-ago).
### Endpoint → field → format
| Endpoint | Field(s) | Format | Notes |
| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | --------------------- | --------------------------------------------------------------------------------------- |
| [`GET /movies/{id}`](/api-reference/simkl/get-movie) | `released`, `release_dates[*].results[*].release_date` | `DATE_ONLY` | Catalog release; no time component. |
| [`GET /tv/{id}`](/api-reference/simkl/get-tv-show) | `first_aired`, `last_aired` | `ISO_UTC_Z` | Series-level air-window markers. |
| [`GET /anime/{id}`](/api-reference/simkl/get-anime) | `first_aired`, `last_aired` | `ISO_UTC_Z` | Same as TV. |
| [`GET /tv/episodes/{id}`](/api-reference/simkl/get-tv-episodes) | `[*].date` | `ISO_WITH_OFFSET` | Per-episode air time in the **network's local timezone** (e.g. `-05:00` for US shows). |
| [`GET /anime/episodes/{id}`](/api-reference/simkl/get-anime-episodes) | `[*].date` | `ISO_WITH_OFFSET` | Per-episode air time in **`+09:00` (Asia/Tokyo)** for the canonical Japanese broadcast. |
| [`GET /tv/airing`](/api-reference/simkl/get-tv-airing) | `[*].date` | `ISO_WITH_OFFSET` | Originating network's timezone. |
| [`GET /anime/airing`](/api-reference/simkl/get-anime-airing) | `[*].date` | `ISO_WITH_OFFSET` | Asia/Tokyo for Japan-originated anime. |
| [`GET /sync/activities`](/api-reference/simkl/get-activities) | every `*_at` field (`all`, `tv_shows.completed`, `anime.dropped`, etc. — 27 fields total) | `ISO_UTC_Z` | All UTC; convert to profile timezone for display. |
| [`GET /sync/all-items/{type}`](/api-reference/simkl/get-all-items) | `[*].added_to_watchlist_at`, `[*].last_watched_at`, `[*].user_rated_at` | `ISO_UTC_Z` | All user-activity timestamps in UTC. |
| [`GET /sync/playback/{type}`](/api-reference/simkl/get-playback-sessions) | `[*].paused_at` | `ISO_UTC_Z` | Pause-event timestamp in UTC. |
| [`POST /users/settings`](/api-reference/simkl/get-user-settings) | `user.joined_at` | `ISO_UTC_Z` | Account creation date. |
| [`GET /calendar/{type}.json`](/api-reference/calendar) *(CDN)* | `[*].date` | `ISO_WITH_OFFSET` | Episode-level air time. |
| [`GET /calendar/{type}.json`](/api-reference/calendar) *(CDN)* | `[*].release_date` | `DATE_ONLY` | Catalog release date alongside the air-time entry. |
| [`GET /calendar/{year}/{month}/{type}.json`](/api-reference/calendar) *(CDN)* | same as rolling calendar | mixed | Same shape, different month window. |
| Every catalog endpoint | `year` | `YEAR_ONLY` (integer) | Release year as an int — convenient for filtering. |
**`ISO_WITH_OFFSET` is authoritative — don't strip it.** The
broadcast `date` field on episode/airing/calendar endpoints encodes
the originating network's timezone deliberately. Stripping the offset
and assuming UTC will silently shift airing schedules by hours (US
shows by 5, Japanese anime by 9). Always parse the timezone too.
**Missing dates** show up as `null` (Type 4 — "data not on file"),
not as the literal string `"null"` or epoch. See [Null and missing
values](/conventions/null-values) for the full nullability model.
The `1970-01-01T00:00:01Z` placeholder is **a different concept** —
it explicitly means "the user watched this but doesn't remember when",
not "we don't know" — see [the dedicated section
below](#very-long-time-ago).
## User timezone preference
Each Simkl member has a **configurable timezone** on their profile, set at [simkl.com/settings/](https://simkl.com/settings/) (with the date and time format alongside it). Third-party apps can read the user's timezone via [`POST /users/settings`](/api-reference/simkl/get-user-settings) — it's returned as the `account.timezone` field, an IANA timezone name (e.g. `"America/New_York"`, `"Europe/Madrid"`, `"Asia/Tokyo"`):
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"user": { … },
"account": {
"id": 12345,
"timezone": "Europe/Madrid",
"type": "vip"
}
}
```
**Use the profile timezone for absolute timestamps when the user is signed in.** That's the single source of truth that keeps Simkl-rendered dates consistent across the user's phone, tablet, TV, and the simkl.com web UI.
| Scenario | What to use |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------ |
| Authenticated user, absolute date (`"Watched on Apr 15 at 8 PM"`) | `account.timezone` from `POST /users/settings`. |
| Authenticated user, relative date (`"Watched 2 hours ago"`) | Device timezone is fine — relative deltas don't depend on absolute zone. |
| Unauthenticated / pre-login | Device timezone as a fallback. |
```js Node theme={"theme":{"light":"github-light","dark":"vesper"}}
// Cache account.timezone after one call to /users/settings.
const PROFILE_TZ = 'America/New_York';
new Date('2026-05-13T16:08:00Z').toLocaleString('en-US', {
timeZone: PROFILE_TZ,
dateStyle: 'medium',
timeStyle: 'short',
});
// → "May 13, 2026, 12:08 PM"
```
```python Python theme={"theme":{"light":"github-light","dark":"vesper"}}
from datetime import datetime
from zoneinfo import ZoneInfo
PROFILE_TZ = ZoneInfo('America/New_York') # from account.timezone
dt = datetime.fromisoformat('2026-05-13T16:08:00+00:00')
dt.astimezone(PROFILE_TZ).strftime('%Y-%m-%d %I:%M %p')
# → "2026-05-13 12:08 PM"
```
The user can change the setting at any time on simkl.com, but it's overwhelmingly **set-and-forget** in practice — don't re-fetch `POST /users/settings` on a timer or on every app launch / wake. Instead, gate the re-fetch on [`/sync/activities`](/api-reference/simkl/get-activities), which returns a `settings.all` timestamp alongside the per-list timestamps. The pattern matches every other Sync surface:
```js theme={"theme":{"light":"github-light","dark":"vesper"}}
// On app launch / when you'd otherwise hit /users/settings:
const a = await getActivities();
if (a.settings.all !== local.lastSettingsSync) {
const settings = await fetchUsersSettings();
local.timezone = settings.account.timezone;
local.lastSettingsSync = a.settings.all;
}
```
That way most launches do **zero** extra calls (the activities check is the cheap "is anything new?" gate you're running for the rest of the sync loop anyway), and you only re-fetch the heavier settings payload on the rare occasion the user actually changed something.
The simkl.com settings UI also lets the user pick a **date format** (e.g. `MM/DD/YYYY`) and a **time format** (12-hour AM/PM vs 24-hour). Those preferences are **not currently exposed** by `POST /users/settings` — only `account.timezone` and `account.type` come back. Until they're added to the API, render dates and times using the device's locale conventions (e.g. `'en-US'` for AM/PM, `'en-GB'` for 24-hour) inside the user's profile timezone.
## "Very long time ago" placeholder
```
1970-01-01T00:00:01Z
```
Simkl uses this near-epoch timestamp as a **deliberate placeholder** meaning *"the user watched this but doesn't remember when"* — typically because they watched the title years ago and don't recall the precise date. It is **not** a NULL, a bug, or missing data. It can appear on any field that carries a watch timestamp: `watched_at`, `last_watched_at`, per-episode `watched_at` inside `seasons[].episodes[]`, and similar.
**On simkl.com,** the "When did you watch this?" date picker offers it as a smart-suggestion card labelled **"Very long time ago"** with the helper line *"I don't remember"*. That's the canonical user-facing label — clients should mirror it (translated to the user's locale).
### Writing the placeholder
When a user picks the "Very long time ago / I don't remember" option in your app's date picker, POST `1970-01-01T00:00:01Z` on the `watched_at` field. This works on every write endpoint that accepts watch timestamps — [`POST /sync/history`](/api-reference/simkl/add-to-history), [`POST /sync/watched`](/api-reference/simkl/get-watched), and so on:
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"movies": [
{ "ids": { "simkl": 53536 }, "watched_at": "1970-01-01T00:00:01Z" }
]
}
```
Simkl stores this as a *"watched, date unknown"* entry and treats it consistently across the API and the simkl.com UI.
### Reading the placeholder
Any `watched_at` / `last_watched_at` value at or near `1970-01-01T00:00:01Z` is the placeholder. Detection rule: **any timestamp before `2000-01-01` is effectively the "date unknown" placeholder.** That guard handles both the canonical epoch+1 second value and any near-epoch variations from legacy data.
Render it in your UI as **"Very long time ago"** (matching simkl.com's label) or a localized equivalent — **never** as the literal `January 1, 1970`, which looks like a parsing error or a corrupt date to end users.
```js Node theme={"theme":{"light":"github-light","dark":"vesper"}}
function formatWatchedAt(iso) {
const d = new Date(iso);
if (d.getFullYear() < 2000) return 'Very long time ago';
return d.toLocaleDateString();
}
```
```python Python theme={"theme":{"light":"github-light","dark":"vesper"}}
from datetime import datetime, timezone
def format_watched_at(iso: str) -> str:
dt = datetime.fromisoformat(iso.replace('Z', '+00:00'))
if dt.year < 2000:
return 'Very long time ago'
return dt.astimezone().strftime('%Y-%m-%d')
```
### Date picker UX — mirror simkl.com
The simkl.com "When did you watch this?" date picker is the reference UX for date selection across the Simkl ecosystem. Apps that surface their own date pickers should mirror its three-tier structure:
**Common times** (quick-pick buttons):
* *Just now* — current timestamp
* *Today* — today at the current time
* *Yesterday* — yesterday at the current time
* *Last week* — 7 days ago at the current time
**Smart suggestions** (context-aware cards):
* *Episode Air Date* — pre-fills the episode's original broadcast date (label: "Original broadcast"). Useful when a user watched live or wants to record a viewing at the air-date for stats / archival purposes.
* *Very long time ago* — the `1970-01-01T00:00:01Z` placeholder. Helper line: *"I don't remember"*. The option that maps to this documented sentinel.
**Recently used** (history): the last two or three timestamps the user picked across recent date selections, surfaced so they don't have to re-build a complex date if they're tagging multiple items at the same time.
Plus the full calendar grid for explicit picks. Third-party apps that build a "When did you watch this?" experience without the *Very long time ago* option force users into picking a fake date — that's worse data for everyone (the user's history, Simkl's stats, your app's later analytics) than just recording an honest *"I don't remember"*.
### Why this exists
Many users adopt Simkl long after they've watched hundreds of titles over the years. They want to record those watches honestly without inventing fake dates. The placeholder lets a client offer the *"I don't remember"* option that maps to a stable, documented server value.
simkl.com renders these entries with the "Very long time ago" label in history lists, sorts them after dated entries, and never displays the literal `1970-01-01` to end users — clients should do the same.
# Errors and status codes
Source: https://api.simkl.org/conventions/errors
Every HTTP status code Simkl returns, what it means, and how to handle it.
Simkl uses standard HTTP status codes. Error responses always have a JSON body with `error` (machine-readable identifier), `code` (integer — the HTTP status code echoed in the body), and an optional `message` (human-readable guidance).
```json title="Standard error envelope" theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"error": "client_id_failed",
"code": 412,
"message": "Your client_id is wrong. Try another one"
}
```
**Branch on `error`, never on `message`.** The `error` identifier is the contract — it's stable across releases. The `message` text is developer-facing prose that may be reworded at any time. Parsing `message` will break your client.
## At a glance
| Code | Name | Retry? |
| ------------------------------- | --------------------- | ------------------------------ |
| [`200`](#ok) | OK | — |
| [`201`](#created) | Created | — |
| [`204`](#no-content) | No Content | — |
| [`302`](#found) | Found | — |
| [`400`](#bad-request) | Bad Request | No — fix the request |
| [`401`](#unauthorized) | Unauthorized | No — re-authenticate |
| [`403`](#forbidden) | Forbidden | No — fix the app or contact us |
| [`404`](#not-found) | Not Found | No |
| [`409`](#conflict) | Conflict | No |
| [`412`](#client-id-failed) | client\_id failed | No |
| [`429`](#too-many-requests) | Too Many Requests | Yes — back off |
| [`500`](#internal-server-error) | Internal Server Error | Yes — exponential backoff |
| [`502`](#bad-gateway) | Bad Gateway | Yes — exponential backoff |
| [`503`](#service-unavailable) | Service Unavailable | Yes — exponential backoff |
**Not every endpoint can return every code.** Each endpoint's reference page lists the codes that actually fire there — many endpoints can only return a subset (e.g., `/sync/activities` can only return `200`, `401`, `412`, `429`, `500`). The 4xx/5xx pages below describe the codes; check the per-endpoint reference for which apply.
## Success codes
### OK
`200` — Success. The body contains the resource you requested.
### Created
`201` — Success. A new resource was created. Typical for `POST /sync/...` and `POST /scrobble/...`.
### No Content
`204` — Success but the response body is empty. Typical for `DELETE` endpoints.
### Found
`302` — A redirect. Follow the `Location` header. Most commonly returned by [the redirect endpoint](/api-reference/simkl/redirect).
## 4xx — your request
### Bad Request
`400` — The request was malformed. The `message` field usually identifies the offending parameter.
**Real `error` values returned**
| `error` | When | Example `message` |
| ----------------- | ------------------------------- | ----------------------- |
| `empty_field` | A required field is missing | `Missed "to" parameter` |
| `wrong_parameter` | A field has an unaccepted value | `Wrong "to" parameter` |
**Fix**
Read `message`, correct the request, then resend. Don't retry without changing the request.
```json title="400 Bad Request" theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"error": "empty_field",
"code": 400,
"message": "Missed \"to\" parameter"
}
```
### Unauthorized
`401` — Missing or invalid user access token.
**Common causes**
* `Authorization` header missing on a token-required endpoint.
* The user's `access_token` was revoked at [Connected Apps settings](https://simkl.com/settings/connected-apps/).
* A typo or truncation in the token value.
**Fix**
Re-authenticate with [OAuth 2.0 or PIN](/authentication). Simkl tokens advertise `expires_in: 157680000` (5 years) on mint, so a 401 in practice means the user removed your app from their account or the token never matched in the first place.
```json title="401 Unauthorized" theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"error": "user_token_failed",
"code": 401
}
```
The 401 response also carries an RFC 6750 §3 `WWW-Authenticate` header for clients that key off it:
```http theme={"theme":{"light":"github-light","dark":"vesper"}}
HTTP/1.1 401 Unauthorized
Content-Type: application/json
WWW-Authenticate: Bearer realm="api.simkl.com", error="user_token_failed"
```
### Forbidden
`403` — Refused. The request was understood but the caller is not permitted to perform it.
**Known `error` values**
| `error` | When |
| ----------------- | ------------------------------------------------------------------------------------------- |
| `redirect_failed` | OAuth/PIN `redirect` parameter doesn't match a URL registered for your app |
| `forbidden` | Generic refusal — feature gated to Simkl Pro/VIP, or an action the user hasn't consented to |
**Fix**
Match the redirect URL exactly to what's registered in your [developer settings](https://simkl.com/settings/developer/). For Pro/VIP-gated features, surface an upgrade prompt rather than retrying.
```json title="403 Forbidden — redirect mismatch" theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"error": "redirect_failed",
"code": 403,
"message": "Invalid redirect_uri. This value must match a URL registered with the API Key."
}
```
### Not Found
`404` — The URL or resource doesn't exist.
**Most catalog endpoints don't return 404 for missing IDs.** Hitting `/movies/99999999` returns `200 []` (empty array), not 404. Use `404` documentation in per-endpoint references as the source of truth — most read-only catalog endpoints simply return empty.
```json title="404 Not Found" theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"error": "id_err",
"code": 404
}
```
### Conflict
`409` — The resource state conflicts with the request.
**Common causes**
* Tried to `POST /scrobble/stop` on a session that completed in the last hour.
* Tried to perform a write that would duplicate an existing record.
**Fix**
Treat `409` as soft-success when scrobbling — the user already finished the episode. Don't retry the same call. The body includes `watched_at` (when the original watch landed) and `expires_at` (when the 1-hour duplicate-window closes), so you can show the user the existing watch state instead of re-firing the call.
```json title="409 Conflict (POST /scrobble/stop)" theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"error": "already_watched",
"watched_at": "2026-05-08T14:00:00Z",
"expires_at": "2026-05-08T15:00:00Z"
}
```
### client\_id failed
`412` — Your `client_id` is missing, wrong, suspended, or has hit a request limit.
**Common causes**
* Typo in `client_id` (use the value from [developer settings](https://simkl.com/settings/developer/), not a screenshot).
* App was suspended for a Terms of Service violation.
* App hit a per-`client_id` request cap — see [Rate limits](/resources/rate-limits).
**Fix**
Double-check the value. If it's correct, [reach out on Discord](https://discord.gg/MJsWNE4).
```json title="412 client_id failed" theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"error": "client_id_failed",
"code": 412,
"message": "Your client_id is wrong. Try another one"
}
```
### Too Many Requests
`429` — Rate limit hit.
**Fix**
Implement [exponential backoff](#exponential-backoff). Cache responses aggressively. Use [Trending](/api-reference/trending) and [Calendar](/api-reference/calendar) CDN files instead of polling. Full guidance: [Rate limits](/resources/rate-limits).
```json title="429 Too Many Requests" theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"error": "rate_limit",
"code": 429
}
```
## 5xx — our problem
### Internal Server Error
`500` — Something broke on our side.
**Fix**
Retry with exponential backoff. If the error persists for more than \~30 seconds, [report it on Discord](https://discord.gg/MJsWNE4).
```json title="500 Internal Server Error" theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"error": "internal",
"code": 500
}
```
### Bad Gateway
`502` — Simkl is being upgraded or briefly unreachable.
**Fix**
Retry with exponential backoff. Check [status](https://status.simkl.com/) for known issues.
### Service Unavailable
`503` — Servers are up but overloaded.
**Fix**
Retry after the delay. Respect any `Retry-After` header.
## Handling errors gracefully
### Exponential backoff
Recommended retry pattern for transient errors (`429`, `500`, `502`, `503`):
| Attempt | Wait before retry |
| ------- | ------------------------- |
| 1 | 1 s |
| 2 | 2 s |
| 3 | 4 s |
| 4 | 8 s |
| 5 | 16 s (give up after this) |
Add jitter (random 0–1 s) so multiple clients don't synchronize.
```js title="retry-with-backoff.js" theme={"theme":{"light":"github-light","dark":"vesper"}}
async function retry(fn, attempts = 5) {
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (err) {
if (i === attempts - 1 || ![429, 500, 502, 503].includes(err.status)) throw err;
const delay = Math.min(1000 * 2 ** i, 60000) + Math.random() * 1000;
await new Promise((r) => setTimeout(r, delay));
}
}
}
```
### Don't retry deterministic errors
`400`, `401`, `403`, `404`, `409`, `412` mean something is wrong with the request itself. Retrying without changing the request just wastes quota and triggers `429`.
### User-facing messaging
`message` is human-readable but written for developers. For end users:
| Code | Suggested user message |
| ----- | ------------------------------------------------------ |
| `401` | *Please sign in again to continue.* |
| `403` | *This action isn't available for your account.* |
| `404` | *We couldn't find that title.* |
| `429` | *Slow down — try again in a moment.* |
| `5xx` | *Simkl is having a moment. We'll retry automatically.* |
### Logging
Always log `error` and `code` together with your request URL. `error` is the stable contract you'll branch on; `code` confirms the HTTP class.
# Extended info
Source: https://api.simkl.org/conventions/extended-info
Which endpoints return rich data by default and which ones still take an `extended` query parameter.
Simkl returns different amounts of data depending on the endpoint. Most lookups now return the rich record by default — you only need the `extended` parameter on a small set of endpoints, and the accepted values vary per endpoint.
## At a glance
| Endpoint family | Default response | `extended` parameter? |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| [`GET /movies/:id`](/api-reference/simkl/get-movie), [`GET /tv/:id`](/api-reference/simkl/get-tv-show), [`GET /anime/:id`](/api-reference/simkl/get-anime) | **Full record** — title, year, ids, overview, genres, ratings, poster, fanart, trailers, recommendations | Not needed. |
| [`GET /tv/episodes/:id`](/api-reference/simkl/get-tv-episodes), [`GET /anime/episodes/:id`](/api-reference/simkl/get-anime-episodes) | **Full episode list** with metadata | Not needed. |
| [`GET /sync/all-items/:type/:status`](/api-reference/simkl/get-all-items) | **Rich summary** — `last_watched`, `last_watched_at`, `watched_episodes_count`, `total_episodes_count`, `next_to_watch`, plus a `show`/`movie`/`anime` block with title, poster, runtime, ids. | Single value: `full`, `full_anime_seasons`, `ids_only`, or `simkl_ids_only` (see [below](#all-items-modes)). |
| [`GET /search/:type`](/api-reference/simkl/search-by-text) (text search) | `title`, `year`, `poster`, `ids` (simkl\_id, slug, tmdb), plus `title_en` / `title_romaji` / `type` for anime. | `extended=full` adds `all_titles`, `url`, `rank`, `status`, `ratings`, and `ep_count` (non-movies). |
| [`GET /search/id`](/api-reference/simkl/search-by-id), [`POST /search/file`](/api-reference/simkl/search-by-file) | Match metadata | `extended` is not used. |
## Summary endpoints — no extended needed
Calling a summary endpoint returns the full media object out of the box:
```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
curl "https://api.simkl.com/tv/17465?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
```
You'll get back overview, ratings, genres, trailers, recommendations — everything that used to require `?extended=full`. The parameter is still accepted for backward compatibility, but it's a no-op on [`/movies/:id`](/api-reference/simkl/get-movie), [`/tv/:id`](/api-reference/simkl/get-tv-show), [`/anime/:id`](/api-reference/simkl/get-anime), [`/tv/episodes/:id`](/api-reference/simkl/get-tv-episodes), and [`/anime/episodes/:id`](/api-reference/simkl/get-anime-episodes).
## [`GET /sync/all-items/:type/:status`](/api-reference/simkl/get-all-items) — multiple modes
This is the only endpoint where `extended` takes one of several distinct mode values (not a comma-separated list). The default response is already **rich** — `extended` modes either **reduce** it to a slimmer ID-only payload (for fast deletion-reconciliation diffs) or **enhance** it with episode-level data and full metadata.
| Value | What you get |
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| *(omitted)* | **Default rich summary** per item: `last_watched`, `last_watched_at`, `watched_episodes_count`, `total_episodes_count`, `not_aired_episodes_count`, `next_to_watch`, `added_to_watchlist_at`, `user_rating`, `user_rated_at`, `status`, plus a `show` / `movie` / `anime` block with title, poster, year, runtime, and ids. |
| `extended=simkl_ids_only` | **Reduces** to just `ids.simkl` per item — smallest possible delta payload, ideal for diffing against a local cache. |
| `extended=ids_only` | **Reduces** to the full `ids` block (Simkl + every external catalog ID) without other metadata. |
| `extended=full` | **Enhances** the default with genres, ratings, full metadata, and a `seasons[].episodes[]` block for items in `watching` / `plantowatch` / `hold` statuses. Episode entries contain `number` only — per-episode `watched_at` requires the separate `episode_watched_at=yes` parameter. |
| `extended=full_anime_seasons` | Same as `extended=full`, plus anime entries gain a show-level `mapped_tvdb_seasons: [n, …]` array (each Simkl/AniDB season's matching TVDB season number) and a per-episode `tvdb: { season, episode }` block. Not applied to anime movies. |
### Related modifiers
Two query parameters interact with `extended` on `/sync/all-items` but are separate flags:
* **`include_all_episodes=yes`** *(or `=original`)* — by default, `extended=full` doesn't load `seasons[].episodes[]` for items in `completed` or `dropped` buckets. Set `include_all_episodes=yes` to load episodes on those buckets too (synthesizes virtual episode rows for items without per-episode data). Use `=original` to load only real per-episode rows, skipping virtual synthesis.
* **`episode_watched_at=yes`** — adds per-episode `watched_at` timestamps to whatever episodes are loaded. Modifier only — episodes must already be present via `extended=full` or `include_all_episodes=yes`. Without this flag, episode entries carry only `number`.
See the [`/sync/all-items` reference](/api-reference/simkl/get-all-items) for the full response shape per mode, and the [Sync guide](/guides/sync) for the two-phase model that puts these together. For rewatch-session reads, see the [Rewatches guide](/guides/rewatches).
## Search — only [`/search/:type`](/api-reference/simkl/search-by-text) uses `extended`
[`/search/:type`](/api-reference/simkl/search-by-text) (free-text search) accepts only `extended=full` (no comma-separated values, no other modes).
**Default match shape** (without `extended`): `title`, `year`, `poster`, `endpoint_type`, plus `ids` (`simkl_id`, `slug`, and `tmdb` when available). Anime entries also include `title_en`, `title_romaji`, and `type` (`tv` / `movie` / `ova` / `ona` / `special` / `music video`).
**With `extended=full`** the response adds:
* `all_titles` — alternate titles / aliases (only when more than one is known for the item)
* `url` — canonical simkl.com URL
* `rank` — Simkl rank for the type
* `status` — running / ended / cancelled / etc.
* `ratings` — Simkl + IMDb + MAL aggregate ratings (each present when available)
* `ep_count` — episode count, **non-movies only** (TV and anime)
[`/search/id`](/api-reference/simkl/search-by-id) and [`/search/file`](/api-reference/simkl/search-by-file) don't honour `extended` — they always return their default match shape. See those endpoints' pages for the actual response fields.
For [`/search/id`](/api-reference/simkl/search-by-id) specifically, use the per-catalog ID query parameter that matches what you have (`imdb`, `tmdb`, `tvdb`, `mal`, etc.). The response shape is fixed; there's no opt-in extension.
# Headers and required parameters
Source: https://api.simkl.org/conventions/headers
Required URL parameters and HTTP headers for every Simkl API request.
Every Simkl API request needs **three URL parameters** that identify your app, plus a **`User-Agent` header**. Endpoints that read or write user data also need an `Authorization: Bearer` token.
## Required URL parameters
Append these to **every** request URL — both public catalog calls and authenticated user calls:
```
/endpoint?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0
```
| Parameter | Required | Value |
| ------------- | -------- | --------------------------------------------------------------------------------------- |
| `client_id` | always | Your `client_id` from [your developer settings](https://simkl.com/settings/developer/). |
| `app-name` | always | Short, lowercase identifier for your app (e.g. `plex-scrobbler`, `kodi-trakt-bridge`). |
| `app-version` | always | The current version of your app, e.g. `1.0`, `2.4.1`. |
These three parameters help us see which apps are using the API, debug issues you report, and route around outages. They're cheap to send — please always include them.
## Required HTTP headers
| Header | Required | Value |
| --------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Content-Type` | on POST | `application/json` |
| `User-Agent` | always | A descriptive identifier for your app, ideally `name/version`. Examples: `PlexMediaServer/1.43.1.10540`, `kodi-simkl/0.9.2`, `MyAppName/2.4.1 (https://myapp.com)`. |
| `Authorization` | when token-required | `Bearer YOUR_ACCESS_TOKEN`. Required for endpoints that read or modify the user's library, scrobble session, ratings, settings, or playbacks. |
As an alternative to the `client_id` query parameter, you may pass it as a `simkl-api-key` header. Either works; query-param form is preferred because it makes the request fully self-describing in URL form.
## Putting it together
A typical authenticated call looks like:
```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
curl "https://api.simkl.com/sync/activities?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json"
```
A typical public call looks like:
```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
curl "https://api.simkl.com/tv/17465?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
```
In the [API Reference](/api-reference/simkl/get-anime-airing) playground these values are auto-filled — paste your `client_id`, `app-name`, `app-version`, and `access_token` once and they'll be reused.
## HTTP verbs
| Verb | Use | Success status |
| -------- | -------------------- | ---------------------------- |
| `GET` | Retrieve a resource. | `200 OK` |
| `POST` | Create or update. | `201 Created` |
| `DELETE` | Remove a resource. | `200 OK` or `204 No Content` |
The full list of status codes Simkl returns lives on the [Errors](/conventions/errors) page.
# Images
Source: https://api.simkl.org/conventions/images
How to fetch poster, fanart, episode, and avatar images, with all available sizes — with live previews of each.
Simkl returns image **paths** (not full URLs) inside `poster`, `fanart`, `episode`, and `avatar` fields. You build the final URL by combining a domain prefix, a category prefix, the image path, a size suffix, and a file extension.
We strongly recommend serving images through **[wsrv.nl](https://wsrv.nl)**, a free image proxy that caches, resizes, and converts on the fly. This minimizes load on Simkl's servers and gives you free image transformations.
All examples below append `&q=90` to the wsrv URL — this matches origin image quality. Without it, wsrv's default `&q=80` re-encodes images at noticeably smaller filesize and lower quality. Setting `&q=` above 90 is rarely useful — sizes balloon past origin without visible quality gain.
## Anatomy of a URL
| Part | Example | Notes |
| --------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Proxy + domain | `https://wsrv.nl/?url=https://simkl.in` | Always start with this. |
| **Category** | `/posters/` | One of `/posters/`, `/fanart/`, `/episodes/`, `/avatars/`. |
| **Image path** | `74/74415673dcdc9cdd` | The value from the API field (`poster`, `fanart`, etc.). |
| **Size suffix** | `_w` | Determines pixel size — see each section below. |
| **Extension** | `.webp` | Always `.webp` for posters, episodes, and every fanart size except `_d`. Two exceptions: fanart `_d` is `.jpg` only ([details](#fanart)), avatars are `.jpg` only ([details](#avatars)). |
| **Quality** | `&q=90` | Quality parameter for the wsrv proxy. `&q=90` matches origin quality (default `&q=80` reduces quality and filesize). Setting `&q=` above 90 is rarely useful — sizes balloon past origin without visible quality gain. |
**Use `.webp` for posters, episodes, and fanart.**
Two `.jpg` exceptions:
* **Fanart `_d`** — original-resolution, darker tone, smaller filesize than `_medium.webp`. JPG only.
* **Avatars** — small enough that the format doesn't matter; served as JPG without the `wsrv.nl` proxy.
## Code samples
Each tab covers a different real-world scenario. All samples handle missing/null paths by falling back to the **wsrv-proxied placeholders** documented in [Fallback when images are missing](#fallback-when-images-are-missing).
Single helper covering all four kinds, with null-safe fallback for posters.
```js Node / TypeScript theme={"theme":{"light":"github-light","dark":"vesper"}}
const SIMKL_IMG_BASE = 'https://wsrv.nl/?url=https://simkl.in';
function imageUrl(path, kind = 'posters', size = '_w', ext = '.webp') {
if (!path) {
if (kind !== 'posters') return null; // hide fanart/episode/avatar — no placeholder
const ph = size === '_s' ? '_s' : '_c';
return `${SIMKL_IMG_BASE}/poster_no_pic${ph}.png`;
}
return `${SIMKL_IMG_BASE}/${kind}/${path}${size}${ext}&q=90`;
}
imageUrl('74/74415673dcdc9cdd', 'posters', '_w');
// → https://wsrv.nl/?url=https://simkl.in/posters/74/74415673dcdc9cdd_w.webp&q=90
imageUrl(null, 'posters', '_c');
// → https://wsrv.nl/?url=https://simkl.in/poster_no_pic_c.png
```
```python Python theme={"theme":{"light":"github-light","dark":"vesper"}}
SIMKL_IMG_BASE = 'https://wsrv.nl/?url=https://simkl.in'
def image_url(path, kind='posters', size='_w', ext='.webp'):
if not path:
if kind != 'posters':
return None # no placeholder for fanart/episode/avatar
ph = '_s' if size == '_s' else '_c'
return f'{SIMKL_IMG_BASE}/poster_no_pic{ph}.png'
return f'{SIMKL_IMG_BASE}/{kind}/{path}{size}{ext}&q=90'
```
```swift Swift theme={"theme":{"light":"github-light","dark":"vesper"}}
enum SimklImage {
static let imgBase = "https://wsrv.nl/?url=https://simkl.in"
static func url(path: String?, kind: String = "posters",
size: String = "_w", ext: String = ".webp") -> String? {
guard let p = path, !p.isEmpty else {
guard kind == "posters" else { return nil }
let ph = size == "_s" ? "_s" : "_c"
return "\(imgBase)/poster_no_pic\(ph).png"
}
return "\(imgBase)/\(kind)/\(p)\(size)\(ext)&q=90"
}
}
```
```kotlin Kotlin theme={"theme":{"light":"github-light","dark":"vesper"}}
object SimklImage {
private const val IMG_BASE = "https://wsrv.nl/?url=https://simkl.in"
fun url(path: String?, kind: String = "posters",
size: String = "_w", ext: String = ".webp"): String? {
if (path.isNullOrEmpty()) {
if (kind != "posters") return null
val ph = if (size == "_s") "_s" else "_c"
return "$IMG_BASE/poster_no_pic$ph.png"
}
return "$IMG_BASE/$kind/$path$size$ext&q=90"
}
}
```
```go Go theme={"theme":{"light":"github-light","dark":"vesper"}}
package simklimg
import "fmt"
const imgBase = "https://wsrv.nl/?url=https://simkl.in"
func URL(path, kind, size, ext string) string {
if path == "" {
if kind != "posters" {
return ""
}
ph := "_c"
if size == "_s" {
ph = "_s"
}
return fmt.Sprintf("%s/poster_no_pic%s.png", imgBase, ph)
}
return fmt.Sprintf("%s/%s/%s%s%s&q=90", imgBase, kind, path, size, ext)
}
```
Generate a `` so the browser picks the right size for the user's screen. The `src` falls back to the wsrv-proxied placeholder when `poster` is null.
```html theme={"theme":{"light":"github-light","dark":"vesper"}}
```
Always set explicit `width` / `height` so the browser reserves layout space and avoids cumulative layout shift (CLS).
A component with built-in fallback and `onError` recovery for stale paths.
```jsx theme={"theme":{"light":"github-light","dark":"vesper"}}
import { useState } from 'react';
const SIMKL_IMG_BASE = 'https://wsrv.nl/?url=https://simkl.in';
const PLACEHOLDER = `${SIMKL_IMG_BASE}/poster_no_pic_c.png`;
export function Poster({ path, size = '_c', alt = '' }) {
const initial = path ? `${SIMKL_IMG_BASE}/posters/${path}${size}.webp&q=90` : PLACEHOLDER;
const [src, setSrc] = useState(initial);
return (
setSrc(PLACEHOLDER)}
/>
);
}
```
`onError` falls back if the image 404s for any reason (e.g. the title was merged and the path is stale).
Show a tiny `_s48` fanart thumbnail upscaled with a CSS blur, then swap in the full image when loaded. **Fanart has no placeholder** — if `path` is null, render nothing.
```jsx theme={"theme":{"light":"github-light","dark":"vesper"}}
import { useState } from 'react';
const SIMKL_IMG_BASE = 'https://wsrv.nl/?url=https://simkl.in';
export function Fanart({ path }) {
const [loaded, setLoaded] = useState(false);
if (!path) return null;
const tiny = `${SIMKL_IMG_BASE}/fanart/${path}_s48.webp&q=90`;
const full = `${SIMKL_IMG_BASE}/fanart/${path}_medium.webp&q=90`;
return (
);
}
```
Use a remote placeholder URL or a bundled image — both work.
```swift theme={"theme":{"light":"github-light","dark":"vesper"}}
import SDWebImage
let url = SimklImage.url(path: poster, kind: "posters", size: "_c")
.flatMap(URL.init(string:))
let placeholderURL = URL(string: "https://wsrv.nl/?url=https://simkl.in/poster_no_pic_c.png")!
imageView.sd_setImage(with: url, placeholderImage: nil) { image, _, _, _ in
if image == nil {
imageView.sd_setImage(with: placeholderURL)
}
}
```
Or with [Kingfisher](https://github.com/onevcat/Kingfisher):
```swift theme={"theme":{"light":"github-light","dark":"vesper"}}
import Kingfisher
let url = SimklImage.url(path: poster, kind: "posters", size: "_c")
.flatMap(URL.init(string:))
imageView.kf.setImage(
with: url,
options: [
.onFailureImage(UIImage(named: "poster_placeholder")),
// …or fetch the wsrv placeholder remotely:
// .alternativeSources([.network(URL(string:
// "https://wsrv.nl/?url=https://simkl.in/poster_no_pic_c.png")!)])
]
)
```
Loading via [Coil](https://coil-kt.github.io/coil/) on Android Compose, with the wsrv-proxied placeholder used both as `placeholder` and `error`:
```kotlin theme={"theme":{"light":"github-light","dark":"vesper"}}
import coil.compose.AsyncImage
import coil.request.ImageRequest
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(SimklImage.url(path = poster, size = "_c"))
.crossfade(true)
.build(),
contentDescription = title,
placeholder = rememberAsyncImagePainter(
"https://wsrv.nl/?url=https://simkl.in/poster_no_pic_c.png"
),
error = rememberAsyncImagePainter(
"https://wsrv.nl/?url=https://simkl.in/poster_no_pic_c.png"
),
contentScale = ContentScale.Crop,
modifier = Modifier.size(width = 170.dp, height = 250.dp),
)
```
## Posters
Six sizes, ranging from cinematic landscape (`_w`) down to a 40-pixel sliver (`_s`).
**`_w`** — 600 × 338, landscape, cropped
**`_m`** — 340 × \*, medium portrait
**`_ca`** — 190 × 279 (or 285), card aspect
**`_c`** — 170 × 250 (or 256), compact card
**`_cm`** — 84 × 124, card mini
**`_s`** — 40 × 57, smallest thumbnail
| Suffix | Size | File size | Notes | Example |
| ------ | ------------------ | --------- | ---------------------------------------- | --------------------------------------------------------------------------------------- |
| `_w` | 600 × 338 | \~41 KB | Landscape, cropped. Hero/header banners. | [View](https://wsrv.nl/?url=https://simkl.in/posters/74/74415673dcdc9cdd_w.webp\&q=90) |
| `_m` | 340 × \* | \~49 KB | Medium portrait. Detail screens. | [View](https://wsrv.nl/?url=https://simkl.in/posters/74/74415673dcdc9cdd_m.webp\&q=90) |
| `_ca` | 190 × 279 (or 285) | \~21 KB | Card aspect. Grids. | [View](https://wsrv.nl/?url=https://simkl.in/posters/74/74415673dcdc9cdd_ca.webp\&q=90) |
| `_c` | 170 × 250 (or 256) | \~18 KB | Compact card. Lists. | [View](https://wsrv.nl/?url=https://simkl.in/posters/74/74415673dcdc9cdd_c.webp\&q=90) |
| `_cm` | 84 × 124 | \~5.7 KB | Card mini. Dense lists. | [View](https://wsrv.nl/?url=https://simkl.in/posters/74/74415673dcdc9cdd_cm.webp\&q=90) |
| `_s` | 40 × 57 | \~1.6 KB | Smallest. Inline / autocomplete. | [View](https://wsrv.nl/?url=https://simkl.in/posters/74/74415673dcdc9cdd_s.webp\&q=90) |
Widths are exact. **Heights are the common cases, not guarantees.** Poster height follows the source image and varies per title. See [Poster height is not fixed](#poster-height-is-not-fixed).
## Choosing a size for high-DPI screens
The Notes column above suggests a size by layout slot. That assumes a 1x display. Phones run at 2x or 3x, and most laptop screens are 2x, so a `_c` poster in a 170px slot gets upscaled by the browser and looks soft.
**The rule:** multiply your CSS slot width by the device pixel ratio, then pick the smallest suffix whose width is greater than or equal to that number.
Poster widths are `_s` 40, `_cm` 84, `_c` 170, `_ca` 190, `_m` 340.
| CSS slot width | 1x | 2x | 3x |
| -------------- | ----- | ----- | ----- |
| 40 px | `_s` | `_cm` | `_c` |
| 60 px | `_cm` | `_c` | `_ca` |
| 84 px | `_cm` | `_c` | `_m` |
| 100 px | `_c` | `_m` | `_m` |
| 113 px | `_c` | `_m` | `_m` |
| 170 px | `_c` | `_m` | `_m` |
| 190 px | `_ca` | `_m` | `_m` |
The practical takeaway: **any poster rendered at up to roughly 170 CSS pixels should use `_m` on a high-DPI screen, not `_c`.**
`_m` at 340px covers a 113px slot at 3x and a 170px slot at 2x. That covers the common phone case, so `_m` is a sensible default for mobile poster art. It is also the largest portrait poster, so above those widths you are already at the ceiling. `_w` is not an alternative here, it is a 600 × 338 landscape crop for banners.
**No poster size has a guaranteed height, and `_m` is the loosest of all.**
`_m` is a width-only resize to 340px. It never crops, so its height is purely whatever the source aspect ratio gives. That is why the table lists it as `340 × *`. Measured across 30 trending titles, `_m` came back between 480px and 513px tall.
`_c`, `_ca`, `_cm` and `_s` are also width-first, but each caps the height at its listed maximum. Titles under the cap keep their own aspect ratio, titles over it are centre-cropped vertically. Their heights vary too, just in a narrower band: the same 30 titles returned `_c` at 241, 250, 255 and 256 pixels tall. `_w` is the one true fixed size, always 600 × 338.
**The consequence:** if your layout constrains only one dimension, for example an `img` with a width but no height, or a fixed-height grid cell, your rows will change size per title. This is already true today with `_c`. Switching to `_m` makes it obvious, because the height spread is wider.
Pin both dimensions (or set `aspect-ratio`) and add `object-fit: cover`. For background images use `background-size: cover`. That reproduces the same centre-crop the capped sizes do server-side, so the result is visually identical to `_c`, just at higher resolution.
```css theme={"theme":{"light":"github-light","dark":"vesper"}}
/* Same visual result as _c, sharper on 2x and 3x screens. */
.poster {
width: 170px;
height: 250px; /* or: aspect-ratio: 170 / 250; */
object-fit: cover;
object-position: center;
}
```
For a background image:
```css theme={"theme":{"light":"github-light","dark":"vesper"}}
.poster-bg {
width: 170px;
aspect-ratio: 170 / 250;
background-image: url("https://wsrv.nl/?url=https://simkl.in/posters/74/74415673dcdc9cdd_m.webp&q=90");
background-size: cover;
background-position: center;
}
```
### Let users cap image quality on mobile
Defaulting to `_m` on phones triples the bytes per poster versus `_c`. On a metered connection that is a real cost, so make it a user choice rather than a hard default.
Ship a setting with three options: full quality, reduce on cellular only, and always reduce. "Reduce" means step down one suffix, so `_m` becomes `_c` and `_c` becomes `_cm`. Default to reducing on cellular.
Also respect the OS-level signal, which users set once and expect every app to honour:
| Platform | Signal |
| -------- | ------------------------------------------------------------------------------------------------------------------------- |
| iOS | Low Data Mode. Check `allowsConstrainedNetworkAccess` on your `URLSessionConfiguration`, or watch `NWPath.isConstrained`. |
| Android | Data Saver. Check `ConnectivityManager.getRestrictBackgroundStatus()`. |
| Web | `navigator.connection.saveData`, plus `effectiveType` for a rough speed hint. |
Cache each size under its own key so toggling the setting does not evict the images the user already has. See [Caching](#caching).
## Fanart
Wide background art — the cinematic still you typically use behind the title on a detail screen.
| Suffix | Size | File size | Notes | Example |
| --------- | ----------- | --------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `_medium` | 1920 × 1080 | \~357 KB | Full HD. Detail-screen hero. | [View](https://wsrv.nl/?url=https://simkl.in/fanart/71/710408ec0a1bd207_medium.webp\&q=90) |
| `_mobile` | 960 × 540 | \~96 KB | Mobile-optimized hero. | [View](https://wsrv.nl/?url=https://simkl.in/fanart/71/710408ec0a1bd207_mobile.webp\&q=90) |
| `_w` | 600 × 338 | \~29 KB | Card-friendly. | [View](https://wsrv.nl/?url=https://simkl.in/fanart/71/710408ec0a1bd207_w.webp\&q=90) |
| `_d` | original | \~103 KB | Darker, smaller filesize. **JPG only — the one fanart size that isn't `.webp`.** | [View](https://wsrv.nl/?url=https://simkl.in/fanart/71/710408ec0a1bd207_d.jpg\&q=90) |
| `_s48` | 48 × 27 | \~1 KB | Tiny placeholder for blur-up effects. | [View](https://wsrv.nl/?url=https://simkl.in/fanart/71/710408ec0a1bd207_s48.webp\&q=90) |
## Episodes
| Suffix | Size | File size | Example |
| ------ | --------- | --------- | ---------------------------------------------------------------------------------------- |
| `_w` | 600 × 338 | \~29 KB | [View](https://wsrv.nl/?url=https://simkl.in/episodes/26/265319260301d2ee2_w.webp\&q=90) |
| `_c` | 210 × 118 | \~8 KB | [View](https://wsrv.nl/?url=https://simkl.in/episodes/26/265319260301d2ee2_c.webp\&q=90) |
| `_m` | 112 × 63 | \~3 KB | [View](https://wsrv.nl/?url=https://simkl.in/episodes/26/265319260301d2ee2_m.webp\&q=90) |
## Avatars
**`_24`** — 24 × 24
**`_100`** — 100 × 100
**`(none)`** — 200 × 200
**`_256`** — 256 × 256
**`_512`** — 512 × 512
| Suffix | Size | File size | Example |
| -------- | --------- | --------- | -------------------------------------------- |
| `_24` | 24 × 24 | \~0.7 KB | [View](https://simkl.in/avatars/1/1_24.jpg) |
| `_100` | 100 × 100 | \~5.6 KB | [View](https://simkl.in/avatars/1/1_100.jpg) |
| *(none)* | 200 × 200 | \~17 KB | [View](https://simkl.in/avatars/1/1.jpg) |
| `_256` | 256 × 256 | \~26 KB | [View](https://simkl.in/avatars/1/1_256.jpg) |
| `_512` | 512 × 512 | \~79 KB | [View](https://simkl.in/avatars/1/1_512.jpg) |
Avatars are JPG-only and served directly from `simkl.in` (no `wsrv.nl` proxy needed for these — they're already small).
## Fallback when images are missing
When a `poster`, `fanart`, or `episode` field is `null` (or the path is empty), use these built-in placeholders. They live at the root of `simkl.in` and are served through wsrv.nl just like the other images:
| URL | Use for | File size | Example |
| ---------------------------------------------------------------- | ---------------------------------- | --------- | ---------------------------------------------------------------------- |
| `https://wsrv.nl/?url=https://simkl.in/poster_no_pic.png` | Default — full-size missing-poster | \~25 KB | [View](https://wsrv.nl/?url=https://simkl.in/poster_no_pic.png) |
| `https://wsrv.nl/?url=https://simkl.in/poster_no_pic_c.png` | Match `_c` posters (170 × 250) | \~9 KB | [View](https://wsrv.nl/?url=https://simkl.in/poster_no_pic_c.png) |
| `https://wsrv.nl/?url=https://simkl.in/poster_no_pic_c_grey.png` | Match `_c` with a softer grey tone | \~24 KB | [View](https://wsrv.nl/?url=https://simkl.in/poster_no_pic_c_grey.png) |
| `https://wsrv.nl/?url=https://simkl.in/poster_no_pic_s.png` | Match `_s` posters (40 × 57) | \~1.7 KB | [View](https://wsrv.nl/?url=https://simkl.in/poster_no_pic_s.png) |
**Fanart has no dedicated placeholder.** When `fanart` is `null`, hide the fanart element rather than showing a broken/empty image — most apps render a solid color or use the poster's blurred-up `_s48` thumbnail behind a tint instead.
## Caching
**Cache images by URL forever.** The image at a given URL never changes — once you've downloaded it, don't fetch it again. Re-downloading wastes your users' bandwidth and ours.
# Watchlist statuses
Source: https://api.simkl.org/conventions/list-statuses
The watchlist statuses Simkl uses — and the per-type rules. Movies don't have watching or hold; TV and anime do.
Every item in a user's library lives in exactly one status bucket. **The set of valid statuses is not the same across types** — movies are restricted to three statuses, while TV shows and anime get all five.
## Per-type matrix
| Status | Movies | TV | Anime | Display name |
| ------------- | :----: | :-: | :---: | ------------- |
| `watching` | — | ✓ | ✓ | Watching |
| `plantowatch` | ✓ | ✓ | ✓ | Plan to Watch |
| `hold` | — | ✓ | ✓ | On Hold |
| `dropped` | ✓ | ✓ | ✓ | Dropped |
| `completed` | ✓ | ✓ | ✓ | Completed |
**TV and anime accept the same five statuses.** The difference that matters in code is **movies vs everything else** — movies skip `watching` and `hold`.
**These are Simkl's built-in *Watchlist* statuses, not user-defined Custom Lists.** Custom Lists — the user-curated, named collections you see at [simkl.com/lists/](https://simkl.com/lists/) — are a separate concept and **not yet available via the API**. See the [Custom Lists guide](/guides/custom-lists) for status and what to use in the meantime.
## Why movies are different
Movies are single-session content — there's no progress to "pause" the way a 10-episode TV season has. Simkl reflects this in the API in four concrete ways:
**1. [`POST /sync/add-to-list`](/api-reference/simkl/add-to-list) silently upgrades `watching` to `completed` for movies.** Posting `to: "watching"` for a movie is not an error — the API quietly converts it to `completed` and records the watch. Posting `to: "hold"` for a movie has no defined behavior; don't rely on it.
**2. [`GET /sync/all-items/movies/watching`](/api-reference/simkl/get-all-items) and `/movies/hold` return empty.** The URL is accepted (won't 404), but no movie can be in those states, so the response is always empty. Don't paginate these endpoints — there's nothing to fetch.
**3. [`GET /sync/activities`](/api-reference/simkl/get-activities) strips `watching` and `hold` from the `movies` block.** Build your client around this shape — don't expect `movies.watching` or `movies.hold` keys.
**4. [`POST /users/USER_ID/stats`](/api-reference/simkl/get-user-stats) skips `watching` and `hold` for movies.** Stats responses don't include those buckets for movies. Plan-to-watch movies show up; "in progress" / "on hold" never do.
## Where statuses appear
`GET /sync/all-items/:type/:status` — read items in one bucket. Use `all` for everything.
`POST /sync/add-to-list` — set `to` to one of the five statuses to move the item between buckets.
`POST /sync/history/remove` — the canonical way to remove a watched item from the user's history.
`GET /sync/activities` returns timestamps per status bucket — only re-sync the watchlists whose timestamp moved.
**To remove an item, use [`POST /sync/history/remove`](/api-reference/simkl/remove-from-history)** — not `POST /sync/add-to-list` with `to: "remove"`. The history endpoint is the canonical removal path; it cleanly un-marks the item watched and clears it from the user's library.
**Removing also clears the rating.** If the user had rated the item, Simkl wipes the rating along with the watch record — you'll see both `removed_from_list` and `rated_at` move on the next `/sync/activities` call.
## Status vs. progress
`status` says **where in the library** an item lives. The `watched_episodes_count`, `total_episodes_count`, and `last_watched_at` fields say **how far the user is**.
A TV show in `status: watching` with 3 of 10 episodes watched is normal. A show in `status: completed` should always have `watched_episodes_count` equal to `total_episodes_count`. The two evolve together but they're separate concepts.
For movies, `status: completed` simply means the user marked the movie watched — there's no episode count to track.
## Quick examples
```bash Read all "watching" anime theme={"theme":{"light":"github-light","dark":"vesper"}}
curl "https://api.simkl.com/sync/all-items/anime/watching?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0" \
-H "Authorization: Bearer ACCESS_TOKEN"
```
```bash Mark a movie completed theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST "https://api.simkl.com/sync/add-to-list?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "to": "completed", "movies": [{ "ids": { "simkl": 53536 } }] }'
```
```bash Drop a TV show theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST "https://api.simkl.com/sync/add-to-list?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "to": "dropped", "shows": [{ "ids": { "tmdb": "1399" } }] }'
```
```bash Pause an anime ("on hold") theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST "https://api.simkl.com/sync/add-to-list?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "to": "hold", "anime": [{ "ids": { "mal": "11757" } }] }'
```
```bash Remove a show from the user's library theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST "https://api.simkl.com/sync/history/remove?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "shows": [{ "ids": { "tmdb": "1399" } }] }'
```
## Related
Read and write watchlists, history, and ratings.
`POST /sync/history` recipes that don't go through scrobble.
The shared shape every watchlist endpoint returns.
# Null and missing values
Source: https://api.simkl.org/conventions/null-values
Five different things a missing value can mean in the Simkl API, and how to handle each one.
`null` in a Simkl response is never noise — it's one of five specific signals. This page is the master reference; schema descriptions link back here by type.
**Never happened yet** — event hasn't fired
**Doesn't apply** — key omitted entirely
**Empty result** — whole response null
**Unknown** — data not on file
**End state** — journey complete
## Type 1 — Never happened yet
**Field present, value `null`** — the event hasn't fired for this user yet, but the field is a normal part of the model. Once it happens, the value flips to a real value and never reverts to `null`.
**Where you'll see it**
| Endpoint | Field | When it's null |
| ------------------------------------------------------------- | ------------------------------ | ------------------------------------------- |
| [`GET /sync/activities`](/api-reference/simkl/get-activities) | every bucket timestamp | User has never had activity in that bucket |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) | `last_watched_at` | Plantowatch items — user hasn't watched yet |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) | `user_rated_at`, `user_rating` | Items the user hasn't rated |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) | `last_watched` | Shows the user hasn't started |
**Handling**
```js JavaScript {2,3} theme={"theme":{"light":"github-light","dark":"vesper"}}
// ✓ Correct
if (item.last_watched_at !== null) {
showLastWatched(item.last_watched_at);
}
// ✗ Wrong — gives a "watched on Jan 1 1970" UI
showLastWatched(item.last_watched_at ?? '1970-01-01T00:00:00Z');
```
```python Python {2,3} theme={"theme":{"light":"github-light","dark":"vesper"}}
# ✓ Correct
if item.get('last_watched_at') is not None:
show_last_watched(item['last_watched_at'])
# ✗ Wrong
show_last_watched(item.get('last_watched_at') or '1970-01-01')
```
## Type 2 — Doesn't apply to this type
**Key omitted entirely** — not set to `null`, the property doesn't exist on this object. Iterating keys won't return it; `'fieldName' in obj` returns `false`.
**Where you'll see it**
| Endpoint | Type | What's omitted |
| -------------------------------------------------------------------------------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------- |
| [`GET /sync/activities`](/api-reference/simkl/get-activities) | `movies` block | `watching` and `hold` keys (movies can't be in those statuses) |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) | Movie records | Episode-related fields (`seasons`, `last_watched`, etc.) |
| Movie / TV records | — | Anime-only IDs (`mal`, `anidb`, `anilist`, `kitsu`) and `anime_type` |
| [`GET /tv/episodes/{id}`](/api-reference/simkl/get-tv-episodes) | TV specials | `season` AND `episode` both omitted on `type: "special"` items |
| [`GET /anime/episodes/{id}`](/api-reference/simkl/get-anime-episodes) | Every anime episode | `season` omitted (AniDB numbers anime sequentially, no per-season concept) |
| [`GET /anime/{id}`](/api-reference/simkl/get-anime) | TV-classified shows | `relations` array omitted entirely (anime catalog only — TV records skip this field) |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) without `?memos=yes` | every entry | `memo` key omitted (gated on the query param) |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) without `?next_watch_info=yes` | every entry | `next_to_watch_info` key omitted |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) without `?allow_rewatch=yes` | rewatch-session rows | `is_rewatch`, `rewatch_id`, `rewatch_status` keys omitted entirely; only the canonical entry returns |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) without `?extended=full_anime_seasons` | anime entries | `mapped_tvdb_seasons` array omitted |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) without `?episode_tvdb_id=yes` | episode rows | `tvdb` block on each episode omitted |
See [Watchlist statuses](/conventions/list-statuses) for the full per-type matrix.
**`/sync/all-items/anime` entries wrap in `show:`, by design.** Each
item in the `anime` array nests its media object under a key called
`show:`, not `anime:`. This is intentional cross-catalog compatibility:
apps that source their data from **TMDB or TVDB** treat anime as shows
(those catalogs have no separate "anime" concept), so the `show:`
wrapper keeps Simkl's sync responses drop-in compatible with code
written against TMDB/TVDB-shaped data. Anime-only fields (`anime_type`,
mal/anidb/anilist/kitsu IDs) sit alongside on the outer item and on
`show.ids` respectively.
```js theme={"theme":{"light":"github-light","dark":"vesper"}}
// ✓ Correct — same iteration pattern works for shows/, anime/, and
// future media types that follow the show-shaped TMDB/TVDB model
const list = await fetch('/sync/all-items/anime').then(r => r.json());
list.anime.forEach(item => {
const metadata = item.show; // anime use `show:` for the same
// reason TVDB indexes anime under shows
const ids = metadata.ids; // ids.simkl, ids.mal, ids.anidb, ...
const animeFormat = item.anime_type; // anime-specific outer-item field
});
```
**Handling**
```js JavaScript {2,5} theme={"theme":{"light":"github-light","dark":"vesper"}}
// ✓ Correct
if ('watching' in activities.tv_shows) {
// TV has a watching bucket
}
// ✗ Wrong — activities.movies.watching is undefined, not null
if (activities.movies.watching === null) {
/* never true */
}
```
```python Python {2,5} theme={"theme":{"light":"github-light","dark":"vesper"}}
# ✓ Correct
if 'watching' in activities['tv_shows']:
pass
# ✗ Wrong — KeyError
ts = activities['movies']['watching']
```
## Type 3 — Empty result set
**The whole response is `null` or an empty array** — the endpoint accepted the request but there are no items to return. The URL is valid; the call returns `200`; the body is just empty.
**Where you'll see it**
| Endpoint | When it's empty |
| --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| [`GET /sync/all-items/movies/watching`](/api-reference/simkl/get-all-items) | Always — movies can't have this status, the route is reachable but the data set is structurally empty |
| [`GET /sync/all-items/movies/hold`](/api-reference/simkl/get-all-items) | Same |
| [`GET /sync/all-items/{type}/{status}`](/api-reference/simkl/get-all-items) | Any bucket the user simply has no items in |
| [`POST /search/random`](/api-reference/simkl/search-random) | Returns `{"error": "not_found"}` when no item matches the filters |
**Handling**
```js JavaScript {3,8} theme={"theme":{"light":"github-light","dark":"vesper"}}
// ✓ Correct
const items = await fetch('/sync/all-items/movies/watching').then(r => r.json());
if (!items) return; // empty bucket — bail early
// ✗ Wrong — TypeError if response is null
const items = await fetch(...).then(r => r.json());
items.movies.forEach(...);
```
```python Python {3,7} theme={"theme":{"light":"github-light","dark":"vesper"}}
# ✓ Correct
data = r.json()
if data is None:
return
# ✗ Wrong — AttributeError
for m in r.json()['movies']:
...
```
## Type 4 — Unknown / data not on file
**Field present, value `null`** — the data point is supposed to have a value but Simkl genuinely doesn't have it yet. Render a placeholder; don't display the literal `null`.
**Where you'll see it**
| Endpoint | Field | When it's null |
| --------------------------------------------------------------------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------ |
| [`GET /tv/airing`](/api-reference/simkl/get-tv-airing) | `date` | Air date not on file yet |
| [`GET /tv/episodes/{id}`](/api-reference/simkl/get-tv-episodes) | `description` | Episode synopsis unknown |
| [`GET /tv/episodes/{id}`](/api-reference/simkl/get-tv-episodes) | `img` | Episode still not on file |
| [`GET /movies/{id}`](/api-reference/simkl/get-movie) | `ratings.imdb`, etc. | No external rating known |
| [`GET /anime/{id}`](/api-reference/simkl/get-anime) | `en_title` | Localized English title not on file (e.g. Death Note returns `null`) |
| [`GET /tv/{id}`](/api-reference/simkl/get-tv-show), [`/anime/{id}`](/api-reference/simkl/get-anime) | `network` | Mirrored from the upstream TVDB record. `null` when TVDB has no network on file (mostly recent anime). |
| [`GET /tv/{id}`](/api-reference/simkl/get-tv-show), [`/anime/{id}`](/api-reference/simkl/get-anime) | `airs` | Recurring schedule not on file (older shows, anime films) |
| [`GET /tv/{id}`](/api-reference/simkl/get-tv-show), [`/anime/{id}`](/api-reference/simkl/get-anime) | `status` | Catalog `Status` column blank — rare; status is normally one of `tba`, `ended`, `airing` |
| Catalog endpoints | `runtime` | Runtime not tracked |
**`network` reflects the upstream catalog (TVDB).** The value is what
TVDB classifies the record under — for streaming-only shows TVDB lists
the platform as the "network" (`"Prime Video"`, `"Netflix"`, `"Apple TV+"`),
for traditional broadcast it's the broadcaster (`"HBO"`, `"AMC"`, `"CBS"`).
Don't infer distribution model from this field; treat it as an opaque
display string from the source catalog.
**`null` vs empty string.** Older Simkl records sometimes return an empty
string `""` for a field rather than `null` (e.g. `en_title: ""` on classic
anime, before the API standardized on `null`). Both mean Type 4 — data
not on file. Check both:
```js theme={"theme":{"light":"github-light","dark":"vesper"}}
const englishTitle = item.en_title || 'No English title on file';
// matches null AND empty string
```
**`memo: {}` is a Type 4 variant.** On
[`GET /sync/all-items?memos=yes`](/api-reference/simkl/get-all-items),
items the user has set a memo on return:
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{ "memo": { "text": "Loved this season", "is_private": false } }
```
Items without a memo return an **empty object**:
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{ "memo": {} }
```
NOT `null`, NOT missing — `{}`. The empty-object marker keeps the key
shape consistent (client code can always do `item.memo.text || ''`
without an existence check or a null-coalesce). When `?memos=yes` is
NOT set, the `memo` key is omitted entirely (Type 2).
**Handling**
```jsx JSX {2,5} theme={"theme":{"light":"github-light","dark":"vesper"}}
// ✓ Correct
{episode.description ?? 'No description yet.'}
// ✓ Correct — guard date-display code
{episode.date ? formatDate(episode.date) : 'TBA'}
```
```python Python {2} theme={"theme":{"light":"github-light","dark":"vesper"}}
# ✓ Correct
runtime_str = f"{movie['runtime']} min" if movie.get('runtime') else "—"
```
## Type 5 — End state reached
**Field present, value `null`** — the value used to be meaningful but the workflow has ended; the field is intentionally cleared. Render the "complete" state in your UI.
**Where you'll see it**
| Endpoint | Field | When it's null |
| ---------------------------------------------------------------------------------- | -------------------- | ----------------------------------------- |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) | `next_to_watch` | Show in `completed` — no further episodes |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) `?next_watch_info=yes` | `next_to_watch_info` | Same — completed show, nothing remaining |
**Handling**
```jsx JSX {2-4} theme={"theme":{"light":"github-light","dark":"vesper"}}
// ✓ Correct
{show.next_to_watch
? Next: {show.next_to_watch}
: All caught up}
```
```python Python {1,3} theme={"theme":{"light":"github-light","dark":"vesper"}}
if show.get('next_to_watch'):
print(f"Next: {show['next_to_watch']}")
else:
print("All caught up")
```
## Empty / Missing response shape — by endpoint
The API uses **five different shapes** for "this request was valid but
there's no data". The table below tells you what to expect at each
endpoint so a single parsing path can handle them all.
### The five shapes you'll meet
Empty array. Safe to iterate.
Top-level `null`. `r.json()` returns `None`.
Empty object. `len()` is 0.
Answer in `Location:` header. No body.
Server returned unrelated discover data instead of an error.
**Status-code lies.** Five endpoints return `200 OK` with an empty
shape rather than `404 Not Found`. Branch on the body shape, not just
the status — see the rows tagged in the table below.
Conversely, three CDN endpoints (`/calendar/...`, `/discover/...`)
return `404` with a **plain string body**, not the JSON error envelope
the rest of the API uses. `r.json()` will raise on these — wrap the
parse.
### Per-endpoint shape matrix
| Endpoint | Trigger | Status | Body shape | How to handle |
| -------------------------------------------------- | --------------------------------------------- | :-----: | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
| `GET /movies/{id}` | unknown numeric Simkl ID | **200** | `[]` | Empty array (Type 3). Safe to iterate. |
| `GET /tv/{id}` | unknown numeric Simkl ID | **200** | `[]` | Same. |
| `GET /anime/{id}` | unknown numeric Simkl ID | **200** | `[]` | Same. |
| `GET /tv/episodes/{id}` | unknown parent show ID | **200** | `[]` | Same. |
| `GET /anime/episodes/{id}` | unknown parent anime ID | **200** | `[]` | Same. |
| `GET /tv/episodes/{id}` | non-numeric `id` | **200** | `[]` | Same. **No 404** for non-numeric id. |
| `GET /anime/episodes/{id}` | non-numeric `id` | **200** | `[]` | Same. |
| `GET /movies/{id}` | non-numeric `id` | 404 | `{error, code, message?}` | JSON error envelope. |
| `GET /tv/{id}` | non-numeric `id` | **200** | `object{...}` | **Fallthrough** — server returns discover data (`top_aired_fanarts`, etc.) instead of an error — NOT a `ShowDetail` shape. |
| `GET /anime/{id}` | non-numeric `id` | **200** | `object{...}` | Same fallthrough behavior. |
| `GET /tv/episodes/` | empty `id` (trailing slash) | 400 | `{error: "empty_id", code: 400}` | JSON error envelope. |
| `GET /anime/episodes/` | empty `id` | 400 | `{error: "empty_id", code: 400}` | Same. |
| `GET /sync/activities` | fresh account (no activity yet) | 200 | `object{}` w/ all-null timestamps | Type 1 nulls inside, never null at top. |
| `GET /sync/all-items` | brand-new user (no library, no `type` filter) | 200 | `{}` | Empty object. Iterating keys yields nothing. |
| `GET /sync/all-items/{type}` | empty bucket | 200 | `object{type: []}` | Object wrapper present; inner array is empty. |
| `GET /sync/all-items/movies/watching` | movies can't be `watching` | **200** | `{}` | **Empty object** — not `null`, not `[]`. Iterating keys yields nothing. |
| `GET /sync/all-items/movies/hold` | movies can't be `hold` | **200** | `{}` | Same. |
| `GET /sync/all-items?memos=yes` | item with no memo set | **200** | `{ "memo": {} }` per item | Empty object inside the per-item shape — NOT null, NOT missing. Type 4 variant. |
| `GET /sync/all-items/badtype` | bad `type` segment | **200** | `object{...}` | **Fallthrough** — server returns a default response with cross-type data. **No 400/404**. |
| `GET /sync/all-items/{type}/badstatus` | bad `status` segment | 200 | `object{type: []}` | Falls back to "all of that type", just empty. **No 400/404**. |
| `GET /sync/ratings/{type}/{1-10}` | user has no ratings at this score | **200** | `{}` | Empty object, NOT empty array. |
| `GET /sync/ratings/{type}/0` | rating \< 1 (out of range) | **200** | `{}` | Same — no validation error. |
| `GET /sync/ratings/{type}/11` | rating > 10 (out of range) | **200** | `{}` | Same. |
| `GET /sync/ratings/badtype/{N}` | bad `type` | **200** | `{}` | Same. |
| `GET /sync/playback/{type}` | empty | 200 | `array[...]` | Returns array (possibly empty). |
| `GET /sync/playback/badtype` | bad `type` | 404 | `{error: "url_failed", code: 404}` | **404 here, but 200 fallthrough on /sync/all-items/badtype** — be aware of the asymmetry. |
| `GET /search/{type}` | missing `q` parameter | **200** | `[]` | Empty array. |
| `GET /search/badtype` | bad type | **200** | `null` | **Top-level null.** `r.json()` returns `None`. |
| `GET /search/id` | unknown external id | **200** | `[]` | Empty array. |
| `GET /search/id` | no id param at all | **200** | `[]` | Same. |
| `POST /search/random` | empty body | **200** | `object{...}` | Returns a random item — never empty. |
| `POST /search/random` | filters yield no match | **200** | `{error: "not_found", ...}` | **Status is 200 but body is the error envelope.** Branch on `'error' in body`, not on status. |
| `POST /search/file` | empty body | **200** | `null` | **Top-level null.** |
| `GET /ratings` | bad params, no id | **200** | `null` | **Top-level null.** |
| `GET /ratings` | unknown imdb id | **200** | `null` | Same. |
| `GET /ratings/{type}` | missing `user_watchlist` param | **200** | `null` | Same — even WITHOUT a Bearer token (auth gate is on the param). |
| `POST /users/settings` | normal | 200 | `object{user, account, connections}` | Always a populated object for the authed user. |
| `POST /users/{user_id}/stats` | unknown `user_id` | **200** | `object{...}` w/ zero stats | Returns a zero-counts record, NOT 404. (Cross-account access intentional.) |
| `GET /users/recently-watched-background/{user_id}` | numeric unknown user | **200** | `null` | **Top-level null** for a numeric-but-unknown id. |
| `GET /users/recently-watched-background/{user_id}` | non-numeric user\_id | 404 | `{error: "user_id_failed", code: 404}` | JSON error envelope. |
| `GET /redirect` | no id at all | **301** | empty body, `Location: //simkl.com` | **301 redirect** — body is empty; answer is in the header. Don't `r.json()`. |
| `GET /redirect` | unknown external id | **301** | empty body, `Location: //simkl.com` | Same — fallback to simkl.com root. |
| `GET /redirect` | valid id | **301** | empty body, `Location: //simkl.com///` | Always 301 — never returns JSON. |
| `GET /oauth/pin/{user_code}` | unknown `user_code` | 200 | `object{...}` (pending status) | Returns the pending-PIN shape, NOT 404. |
| `GET /calendar/{type}.json` *(CDN)* | normal | 200 | `array[...]` | — |
| `GET /calendar/{year}/{month}/{type}.json` *(CDN)* | far-future month | **404** | **plain string body** | **`r.json()` will raise.** Wrap the parse. |
| `GET /calendar/badtype.json` *(CDN)* | bad type | **404** | **plain string body** | Same. |
| `GET /discover/trending/{file}.json` *(CDN)* | normal | 200 | `array[...]` or `object{...}` (combined) | — |
| `GET /discover/trending/badcombo.json` *(CDN)* | unknown filename | **404** | **plain string body** | Same — CDN errors are not JSON. |
### Defensive parser pattern
If you're writing a generic Simkl client wrapper, this defensive
pattern handles every shape above:
```js JavaScript theme={"theme":{"light":"github-light","dark":"vesper"}}
async function safeJson(response) {
// CDN 404s and the redirect-without-follow are not JSON.
if (response.status >= 300 && response.status < 400) return null;
if (!response.headers.get('content-type')?.includes('json')) return null;
let body;
try { body = await response.json(); }
catch { return null; } // bad-content-type 404 string
if (body === null) return null; // top-level null (Type 3)
if (Array.isArray(body) && body.length === 0) return []; // empty array
if (typeof body === 'object' &&
Object.keys(body).length === 0) return {}; // empty object
if (body && typeof body === 'object' &&
'error' in body && response.status === 200) {
// 200-with-error-envelope (e.g. POST /search/random with no match)
return null;
}
return body;
}
```
```python Python theme={"theme":{"light":"github-light","dark":"vesper"}}
def safe_json(r):
"""Returns the parsed body or None for any "no useful data" shape."""
if 300 <= r.status_code < 400:
return None # redirect — header has the answer
ct = (r.headers.get("content-type") or "").lower()
if "json" not in ct:
return None # CDN 404 string body
try:
body = r.json()
except ValueError:
return None
if body is None:
return None # top-level null
if isinstance(body, list) and not body:
return [] # empty array
if isinstance(body, dict):
if not body:
return {} # empty object
if r.status_code == 200 and "error" in body:
return None # 200-with-error envelope
return body
```
### Pinning a shape in your code
If your integration depends on a specific row of this table, pin it as
a comment in your code. When the table changes on a future doc release,
your in-code reference makes the diff easier to spot during a routine
dependency-update review.
## Quick decision table
| Symptom | Type | Means | Do |
| -------------------------------- | :---: | --------------------- | -------------------------------------- |
| Timestamp `null` on activities | **1** | Never happened yet | Don't show "last activity" |
| Key isn't in the object | **2** | Doesn't apply to type | `'key' in obj` check, or skip silently |
| Whole response is `null` | **3** | Bucket is empty | Render empty state |
| Description/image is `null` | **4** | Data not on file | Render placeholder ("TBA", "—") |
| `next_to_watch` `null` on a show | **5** | Show is completed | Render "all caught up" |
## Related
Which statuses are valid per type. The reason `watching` and `hold` are omitted from movie blocks (Type 2).
Field-by-field shape of Movie / Show / Anime / Episode — which fields can be null, which are always present.
ISO-8601, the `Z` suffix, and the `1970-01-01T00:00:01Z` "very long time ago" placeholder.
How error envelopes differ from null responses, and when you'll see each.
# Pagination
Source: https://api.simkl.org/conventions/pagination
How paginated endpoints work — request size, page numbers, and the response headers Simkl returns.
Some Simkl endpoints return many items and are paginated. Paginated endpoints are marked with **📄 Pagination** in the [API Reference](/api-reference/introduction).
The pagination contract is consistent across these endpoints — same `page` and `limit` query parameters, same `X-Pagination-*` response headers, same cap on `page` (max **20**) — but **the default and maximum `limit` differ per endpoint family.** Always check the per-endpoint defaults below before assuming a value.
## Per-endpoint defaults
| Endpoint family | Default `limit` | Max `limit` | Max `page` |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | ----------- | ---------- |
| [`GET /search/{type}`](/api-reference/simkl/search-by-text) *(text search)* | **10** | 50 | 20 |
| [`GET /tv/genres/...`](/api-reference/simkl/get-tv-genres), [`GET /anime/genres/...`](/api-reference/simkl/get-anime-genres), [`GET /movies/genres/...`](/api-reference/simkl/get-movies-genres) *(by genre)* | **60** | 60 | 20 |
| [`GET /tv/premieres/{param}`](/api-reference/simkl/get-tv-premieres), [`GET /anime/premieres/{param}`](/api-reference/simkl/get-anime-premieres) | **60** | 60 | 20 |
`page` defaults to `1` on every paginated endpoint.
Endpoints not listed here either don't paginate (they use a different model — [`GET /sync/all-items`](/api-reference/simkl/get-all-items) uses `date_from` for incremental sync) or accept a much larger fixed window (e.g. [`GET /sync/playback`](/api-reference/simkl/get-playback-sessions) returns up to 10,000 items in one shot). When in doubt, check the endpoint's own reference page.
## Query parameters
| Parameter | Type | Description |
| --------- | --------------- | ---------------------------------------------------------------------------- |
| `page` | integer (1–20) | Which page to return. Defaults to `1`. |
| `limit` | integer (1–max) | Items per page. Default and maximum vary per endpoint — see the table above. |
```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
# Page 2 of TV genre browse, 60 items per page (the max for this endpoint).
curl "https://api.simkl.com/tv/genres/all/all/all/all/all/popularity?page=2&limit=60&client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
```
If you pass a `limit` above the endpoint's cap, the server silently clamps it down — no error, no warning, just fewer items than you asked for. Same for `page` above `20`. Read the response headers to know what you actually got.
## Response headers
Every paginated response includes:
| Header | Meaning |
| ------------------------- | --------------------------------------------------------------------- |
| `X-Pagination-Page` | The current page number. |
| `X-Pagination-Limit` | Items per page (after the server applies any clamping). |
| `X-Pagination-Page-Count` | Total number of pages available — bounded by the `page` cap (max 20). |
| `X-Pagination-Item-Count` | Total number of items matching the request across all pages. |
**Stop on `X-Pagination-Page-Count`.** When `X-Pagination-Page` equals `X-Pagination-Page-Count`, you've fetched the last accessible page. The `Item-Count` can exceed `Page-Count × Limit` if the underlying result set is larger than the 20-page cap allows — `Page-Count` is the practical ceiling.
## A simple paginator
These snippets default to a conservative `limit=50` that fits inside every paginated endpoint's cap. Bump to `60` if you're hitting a genre or premieres endpoint and want bigger pages.
```js Node theme={"theme":{"light":"github-light","dark":"vesper"}}
async function* paginate(url, headers, limit = 50) {
let page = 1;
while (true) {
const res = await fetch(`${url}?page=${page}&limit=${limit}`, { headers });
yield await res.json();
const totalPages = Number(res.headers.get('X-Pagination-Page-Count'));
if (page >= totalPages) return;
page++;
}
}
```
```python Python theme={"theme":{"light":"github-light","dark":"vesper"}}
import requests
def paginate(url, headers, limit=50):
page = 1
while True:
res = requests.get(url, params={'page': page, 'limit': limit}, headers=headers)
yield res.json()
total_pages = int(res.headers.get('X-Pagination-Page-Count', '1'))
if page >= total_pages:
return
page += 1
```
**The hard page cap is 20.** No matter how many results match, you can never fetch beyond page 20 — that's `20 × max_limit = 1,200` items in the best case. If you need to walk a larger result set, narrow the query with the endpoint's own filter parameters (genre, year, country, network, sort) rather than paging deeper.
# Standard media objects
Source: https://api.simkl.org/conventions/standard-media-objects
The shape of Movie, Show, Anime, and Episode objects every endpoint accepts and returns.
Every endpoint that talks about a movie, show, anime, or episode uses the same JSON shapes. Learn them once and you can read or write any endpoint.
## The `ids` object
The `ids` object is the heart of every media object. It's how Simkl identifies what you mean across all the catalogs you can integrate with. Pass as many IDs as you have — Simkl resolves to the canonical record and returns the rest.
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"ids": {
"simkl": 53536,
"imdb": "tt0181852",
"tmdb": "296"
}
}
```
Note the quoting: `simkl` is an integer, every other ID is a string. That's exactly how responses come back, and requests accept this form too — see [Supported ID keys](#supported-id-keys) below.
### Supported ID keys
**Read either `ids.simkl` or `ids.simkl_id` — same integer, two key names.** Depending on the endpoint, Simkl's canonical catalog ID comes back as `ids.simkl` or `ids.simkl_id`. Robust reader code accepts both:
```js theme={"theme":{"light":"github-light","dark":"vesper"}}
const id = item.ids.simkl ?? item.ids.simkl_id;
```
The integer is **globally unique and permanent** — use it as your primary key for caching and cross-referencing.
**`slug` is NOT unique** — multiple titles can share one (e.g. three different *Superman* movies live at `/movies/2059585/superman`, `/movies/260740/superman`, `/movies/951252/superman`). The slug is a URL hint only; never identify a record by slug alone.
**External IDs in `ids` (`imdb`, `tmdb`, `tvdb`, `mal`, `anidb`, …) are echo-only.** To resolve one to a Simkl record, use [`/redirect`](/api-reference/simkl/redirect) — it returns the canonical Simkl URL with the `simkl` ID baked in.
**`tmdb` is the one external ID that isn't globally unique** — TMDB keeps separate sequences for `movie` and `tv`, so the same numeric `tmdb` value can refer to either. When resolving a TMDB ID via [`/redirect`](/api-reference/simkl/redirect), always pass `type=movie` or `type=tv` alongside `tmdb`. When reading TMDB IDs back from responses, treat the container key (`movies` / `shows` / `anime`) as part of the ID's identity.
**In responses, every ID is a string except `simkl` / `simkl_id`.** `ids.simkl` (and `ids.simkl_id`) come back as JSON **integers**; every other ID — including numeric-looking ones like `tmdb`, `tvdb`, `mal`, `anidb`, `anilist`, and `kitsu` — comes back as a **quoted string**:
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
"ids": {
"simkl": 1321382,
"slug": "dune-part-two",
"tmdb": "693134",
"imdb": "tt15239678",
"tvdb": "290272"
}
```
Type your decoder accordingly — a struct that declares `tmdb` as `Int` will fail to decode. **On requests, both forms are accepted** (`"tmdb": 693134` and `"tmdb": "693134"` both resolve), so you only need to care about this when reading.
The table below gives the **response** type for each key.
| Key | Type in responses | Example | Notes |
| -------------------- | ----------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `simkl` / `simkl_id` | **integer** | `1321382` | Simkl's canonical ID. **The only integer in `ids`.** Globally unique and permanent. |
| `slug` | string | `"dune-part-two"` | URL hint only — **not unique**, never identify a record by it. |
| `imdb` | string | `"tt15239678"` | |
| `tmdb` | string | `"693134"` | **Not unique across types** — pair with `type=movie` or `type=tv` when resolving via `/redirect`. TMDB has no anime type; anime is filed under `tv`. |
| `tvdb` | string | `"290272"` | |
| `tvdbslug` | string | `"dune-part-two"` | |
| `traktslug` | string | `"dune-part-two-2024"` | |
| `letterboxd` | string | `"dune-part-two"` | Movies only. |
| `mal` | string | `"16498"` | Anime. |
| `anidb` | string | `"9541"` | Anime. |
| `anilist` | string | `"16498"` | Anime. |
| `kitsu` | string | `"7442"` | Anime. |
| `mdlslug` | string | `"808074-howl-s-moving-castle"` | MyDramaList; appears on some anime / drama titles. |
**Accepted on requests but not returned in responses.** These resolve a title when you send them to [`/redirect`](/api-reference/simkl/redirect) or [`/search/id`](/api-reference/simkl/search-by-id), but Simkl doesn't echo them back in `ids`: `crunchyroll`, `netflix`, `hulu`, `anisearch`, `animeplanet`, `livechart`, `anfo`, `ann`, `tvcom`, `zap2it`, `mdl`, `boxd`.
**The CDN data files use different key names than the API.** Everything above describes `api.simkl.com`. The pre-built JSON files on `data.simkl.in` — [trending](/api-reference/trending), [DVD releases](/api-reference/trending), and [calendar](/api-reference/calendar) — are produced by a separate pipeline that still emits the older movie/series-qualified names:
| API (`api.simkl.com`) | CDN files (`data.simkl.in`) |
| --------------------- | --------------------------------------------------- |
| `letterboxd` | `letterslug` |
| `traktslug` | `traktmslug` on movies, `trakttvslug` on TV / anime |
| *(not returned)* | `jwtv` — JustWatch, on TV / anime |
`simkl_id`, `slug`, `imdb`, `tmdb`, `tvdb`, `tvdbslug`, `mdlslug`, `mal`, `anidb`, `anilist`, and `kitsu` are spelled the same in both. If you read from both surfaces, normalize the three keys above before merging — otherwise the same title looks like it has different IDs depending on where you fetched it.
**Send every identifier you have — `title`, `year`, and the full `ids` object.**
Simkl walks the `ids` object in priority order (`simkl` first when present, then external IDs like `imdb`, `tmdb`, `tvdb`, `mal`, `anidb`, …). If no ID resolves, it falls back to a `title` + `year` match, then to title-only as a last resort. Sending everything you know — title, year, plus every external ID your client has cached — maximizes the chance the right item gets credited. Extra fields are free; missing fields can cause a `404 id_err` or, worse, a silent mismatch.
**You don't need to search before writing.** Endpoints like `/scrobble/*`, `/sync/history`, `/sync/add-to-list`, and `/sync/ratings` resolve IDs server-side — pass whatever you have directly, no `/search/*` round-trip required. Calling `/search/id` first to "resolve" a Simkl ID is wasted work that doubles your API quota for no gain.
See [Supported ID keys](/conventions/standard-media-objects#supported-id-keys) for the full list.
## Movie
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"title": "Terminator 3: Rise of the Machines",
"year": 2003,
"ids": {
"simkl": 53536,
"imdb": "tt0181852",
"tmdb": "296"
}
}
```
When `simkl` is known, the minimum is just:
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{ "ids": { "simkl": 53536 } }
```
## Show
A show object can include nested seasons and episodes for partial sync:
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"title": "The Walking Dead",
"year": 2010,
"ids": {
"simkl": 2090,
"tvdb": "153021",
"imdb": "tt1520211"
},
"seasons": [
{
"number": 1,
"episodes": [
{ "number": 1 },
{ "number": 2 }
]
}
]
}
```
## Anime
Same JSON shape as Show, plus `anime_type` and an optional flat `episodes` list. Simkl uses AniDB as its primary metadata source — episodes follow the **anime-native model** by default (each cour is its own title, episodes restart at 1). Simkl also accepts Western TVDB/TMDB-style `season` + `number` coordinates and cross-maps both to the same canonical episode.
Two integration paths (TMDB/TVDB-primary with `use_tvdb_anime_seasons`, or anime-native with AniDB/MAL/AniList/Kitsu IDs), the cross-mapping rules, write/read recipes, and edge cases like Attack on Titan and Solo Leveling.
**Anime can go under either `shows[]` / `show` or `anime[]` / `anime`.** Simkl resolves items by their `ids` first, then routes to the correct catalog — the key you pick is for clarity, not routing. Match the field to your data type when known; fall back to `shows` / `shows[]` when you only have TMDB / TVDB IDs.
| Endpoint family | Accepted top-level keys |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| [`/scrobble/start`](/api-reference/simkl/scrobble-start), [`/pause`](/api-reference/simkl/scrobble-pause), [`/stop`](/api-reference/simkl/scrobble-stop), [`/checkin`](/api-reference/simkl/scrobble-checkin) | `movie`, `show`, `anime`, `episode` *(singular objects, one per request)* |
| [`/sync/history`](/api-reference/simkl/add-to-history), [`/sync/history/remove`](/api-reference/simkl/remove-from-history), [`/sync/add-to-list`](/api-reference/simkl/add-to-list), [`/sync/ratings`](/api-reference/simkl/add-ratings), [`/sync/ratings/remove`](/api-reference/simkl/remove-ratings) | `movies[]`, `shows[]`, `anime[]`, `episodes[]` *(plural arrays, batched)* |
| [`/sync/watched`](/api-reference/simkl/get-watched) | top-level array of items, no wrapping key |
**Anime-only IDs work anywhere** — `anidb`, `mal`, `anilist`, `kitsu`, `anisearch`, `animeplanet`, `livechart` inside `ids` resolve correctly whether the wrapper is `shows[]` or `anime[]`. For example, either of these adds Attack on Titan:
```json POST /sync/history — either form works theme={"theme":{"light":"github-light","dark":"vesper"}}
{ "anime": [ { "ids": { "anidb": 9541 } } ] }
{ "shows": [ { "ids": { "anidb": 9541 } } ] }
```
**Caveat: `not_found` always buckets under `shows`.** If a write fails to resolve, the response's `not_found.shows` array carries the failed entry regardless of whether you sent it under `shows[]` or `anime[]`. There is no `not_found.anime` bucket.
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"title": "Attack on Titan",
"year": 2013,
"anime_type": "tv",
"ids": {
"simkl": 39687,
"mal": "16498",
"tvdb": "267440",
"imdb": "tt2560140",
"anidb": "9541"
},
"episodes": [
{ "number": 1 },
{ "number": 2 }
]
}
```
### `anime_type`
Anime items include an `anime_type` field with one of these values:
* `tv`
* `special`
* `ova`
* `movie`
* `music video`
* `ona`
## Episode
Inside a `seasons[].episodes[]` array (or a flat `episodes` array), an episode is identified by `season` + `number`, **or** directly by `ids`. Optionally include `watched_at` to record when it was watched.
**Prefer `season` + `number` over episode IDs whenever you can.** The numeric `S1E4` coordinate is stable forever — it never changes once an episode airs. External episode IDs (TVDB / AniDB) get re-issued when the source catalog merges duplicates, re-numbers a season, or replaces a record, and **a stale episode ID returns `404`**. The `season` + `number` pair is the cheapest, most robust identifier for scrobble / sync writes — only fall back to `episode.ids` when your integration genuinely doesn't know which season/number it's dealing with (e.g. Plex-style scrapers that resolve a file to a single episode ID without season context).
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"watched_at": "2014-09-01T09:10:11Z",
"season": 1,
"number": 4
}
```
If you only have an episode ID (no season/number), pass it via `ids`. For episode-ID lookups in scrobble / sync writes, Simkl accepts **only `tvdb` and `anidb`** at the episode level. If both are sent, `tvdb` is tried first. Episode-level `imdb` and `tmdb` IDs do **not** exist on Simkl — those external services only assign IDs at the show/movie level.
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"watched_at": "2014-09-01T09:10:11Z",
"ids": {
"tvdb": 4274616,
"anidb": 142278
}
}
```
**`tvdb` and `anidb` are request-only at the episode level.** Catalog responses (e.g. [`/tv/episodes/{id}`](/api-reference/simkl/get-tv-episodes), [`/anime/episodes/{id}`](/api-reference/simkl/get-anime-episodes)) return a single key in each episode's `ids` — `simkl_id`, an **integer**, Simkl's own episode ID:
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
"ids": { "simkl_id": 1195016 }
```
They do not echo `tvdb`, `anidb`, `hulu`, or `crunchyroll` back. Always send `season` + `number` (preferred) or `tvdb` / `anidb` when identifying an episode in a request.
## Tips for sending media objects
`title`, `year`, and any IDs you have. Simkl uses all of it to disambiguate. If two movies share a title and year, IDs save the day.
If you send `simkl: 53536` and `imdb: "tt9999999"` and they disagree, Simkl uses the `simkl` ID. Send only one source of truth when you can.
Simkl uses the anime-native model (each cour is its own title, episodes restart at 1) — same as AniDB / MAL / AniList / Kitsu / AniSearch / Anime-Planet / LiveChart / ANN. Western TV catalogs (TVDB / TMDB / IMDB) use the franchise-with-seasons model instead. The Scrobble and Sync endpoints accept both schemes and Simkl maps automatically — the response includes both `season`/`number` (anime-native) and `tvdb_season`/`tvdb_number` (Western style) for reference. See [Anime episode numbering](/guides/scrobble#anime-episode-numbering) for the full mapping rules.
# API Analytics
Source: https://api.simkl.org/debug/api-analytics
Live per-app request log — see exactly what your code sends to api.simkl.com, filter it, export it.
Every registered Simkl app has a built-in **Analytics view** with **up to 24 hours** of individual requests your app made against `api.simkl.com`, plus a **7-day rollup** of daily request totals. It's the fastest way to confirm what your code is actually sending — without setting up your own logger.
## Opening it
Open **[simkl.com/settings/developer/](https://simkl.com/settings/developer/)** while logged in. The page lists every app you've registered.
Click the app to land on its page (URL: `simkl.com/settings/developer/{APP_ID}/`). This is also where your `Client ID` and `Client Secret` live.
Click **Debug** (top-right of the app card). The analytics view opens in a new tab.
The page is opened via a short-lived signed link. The link **expires after 1 hour** — close and re-open the Debug link from your developer dashboard for a fresh session. The dashboard page is what authorizes you; the analytics URL itself isn't bookmarkable.
## What you can do here
* **Confirm your code is actually hitting Simkl.** Issue a request from your app, hit Refresh — if it doesn't appear, your client never reached us (wrong host, blocked by a proxy, or missing the [required headers](#headers-required-to-show-up) so we can't attribute it to your app).
* **Verify your sync loop matches the [two-phase pattern](/guides/sync) the Sync guide requires.** Apps that don't follow it download the whole user library every poll and get rate-limited — sometimes blocked. The Debug view makes both halves easy to audit:
1. Filter on `Path equals /sync/activities` — this is the cheap "did anything change?" check that has to fire **first** on every poll. If it isn't there, your loop is broken.
2. Filter on `Path contains /sync/all-items` and look at the **Query** column for every row. Each one should include `date_from=` so Simkl returns only the delta. A `/sync/all-items` row with no `date_from` means your client is asking for the whole library — fix it before it gets throttled.
* **See exactly what your OAuth library / SDK puts on the wire.** Wrappers and middleware obscure this; the Debug view shows the literal `client_id`, `app-name`, `app-version`, and `User-Agent` your library sent — useful for diagnosing `empty_field` and `grant_error` responses.
* **See the HTTP status Simkl returned, even when your client only shows a generic error.** Some HTTP libraries surface "Network error" or "Bad Request" without the actual status code. The **Edge** and **Origin** columns show `200` / `401` / `403` / `412` etc. for every request — pair the code with [/conventions/errors](/conventions/errors) to map it back to the meaning. Response bodies aren't recorded in the Debug view, but for `GET` requests the **Link** column re-fires the same URL in a new browser tab so you can read the live response body. `POST` / `PUT` / `DELETE` bodies still have to come from your own logging.
* **Spot retry loops, runaway timers, and duplicate calls.** The red **Burst** column and **High Traffic Burst Detected** banner surface "N requests in 1 second from the same IP" patterns automatically — find which path your code is over-firing before the auto-blocker does.
* **Tell edge cache hits from real backend responses.** When `Edge=200` and `Origin` is blank, the response came from the edge cache without touching the backend — useful for understanding why a stale value sometimes persists after a write.
* **Compare staging vs prod, or app A vs app B.** Filter on `User agent` or `Client IP` to slice traffic by deployment / machine and confirm each environment is sending what you think it is.
* **Export a CSV** of the current filtered view to drop into a spreadsheet, share with support, or attach to a bug report.
## Controls
| Control | What it does |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **TIME** | Look-back window. Anywhere from `1h` to `24h`. Default `6h`. |
| **LIMIT** | Max events per load. Default `100`, max `1000`. |
| **Load** | Apply the current TIME + LIMIT and pull the events. |
| **Refresh** | Re-pull with the same settings. |
| **Mask** | One-click redact of sensitive query-string values (`code`, `token`, `access_token`, `refresh_token`, `redirect`, `redirect_uri`, `pin`) as `***` so screenshots and shared views don't leak secrets. Off by default. |
| **Copy ID** | Copies your `client_id` to the clipboard. |
| **Export** | Downloads the current (filtered) event list as CSV. |
## What each column shows
| Column | Meaning |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Time** | When the request hit Simkl. Defaults to **New York time** (EDT/EST); the column header shows the timezone label so you know what you're looking at. |
| **Burst** | Red `N` badge when N≥3 requests landed in the same second from the same IP. Single requests show nothing. |
| **Method** | `GET`, `POST`, etc. |
| **Scheme** | `https` (or `http` if your client is misconfigured). |
| **Path** | The endpoint hit. `/oauth/pin`, `/sync/all-items/...`, etc. |
| **Query** | Querystring as sent. Click the **Mask** control above to redact sensitive parameters (`code`, `token`, `access_token`, `refresh_token`, `redirect`, `redirect_uri`, `pin`) as `***` before screenshotting or sharing the view. Off by default. |
| **App Ver** | Value your client sent in the `app-version` query parameter. Column only appears if any visible event has it. |
| **Link** | Opens the full request URL in a new browser tab — re-fires the same `GET` against `api.simkl.com` and shows the response body inline. Handy for `GET` rows where you want to see the actual JSON your code received. (Doesn't replay POST / PUT / DELETE — the browser address bar can only issue GETs.) |
| **Edge** | HTTP status from Simkl's edge layer. |
| **Origin** | HTTP status from Simkl's backend. Usually the same as Edge; differs when the response was served from the edge cache without touching the backend (Edge `200`, Origin blank). |
| **Origin latency (ms)** | How long the backend took to produce the response, when measured. |
| **Cache status / Cache response** | Whether the request hit the edge cache, and what status it cached. |
| **Client IP** | A short opaque ID like `e-JgphVReMb9` — not your real IP. Same machine = same ID, so you can group "all the calls from this user" without exposing addresses. |
| **Country** | Where the request came from. |
| **User agent** | The `User-Agent` header your client sent — useful for telling staging and prod apart. |
## Burst detector
When 3+ requests from the same IP land in the same second, the page surfaces a red **High Traffic Burst Detected** banner at the top of the table plus a `X% BURST` badge near the event count. The Burst column highlights the specific rows that fired it.
The three usual culprits:
* **Retry loops without backoff** — fix with exponential backoff + jitter.
* **Parallel requests** — fire calls **sequentially**, awaiting each response before issuing the next. Don't `Promise.all` the same endpoint, don't let a new poll tick start before the previous one returned.
* **UI effects re-firing every render** — gate your `useEffect` / equivalent on a stable dependency.
Repeatedly tripping the banner can get your `client_id` throttled or temporarily blocked. Fix the loop before the auto-blocker fires — recovery isn't automatic.
## Filters
The **Filter results...** box does a free-text match across all visible columns.
**+ Add filter** opens a composer. Pick a column, an operator (`equals`, `not equals`, `contains`, `not contains`, `starts with`, `ends with`, `in`, `not in`), and a value. The value field autocompletes from what's actually in the data. Stack multiple filters — they AND together.
A few recipes:
* **Just failures** → `Origin contains 4` (catches 4xx)
* **One endpoint** → `Path equals /sync/history`
* **One country** → `Country equals US`
* **One machine** → `Client IP equals e-JgphVReMb9` (click the IP in any row to filter to its sibling requests)
**Exclude detail pages** is a one-click filter that hides per-record detail endpoints (typically the chattier ones), so the burst detector and the table focus on your sync/scrobble calls.
## Headers required to show up
**Requests without identifying parameters don't appear in your analytics.** Simkl matches events to your app by the `client_id` it sees in the request — so every API call has to include:
* `client_id` query parameter
* `app-name` and `app-version` query parameters
* `User-Agent` header in the form `my-app-name/1.0`
See [Headers](/conventions/headers) for the full reference. The minimum that works:
```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
curl "https://api.simkl.com/?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
```
Calls without these get attributed to the anonymous bucket and never surface in your Debug view.
## See also
Developer dashboard — register a new app or jump to your existing apps' Debug views.
The `client_id` / `app-name` / `app-version` / `User-Agent` reference.
Per-app daily quotas, per-IP throttling, the 20-second write lock.
What each error envelope means when the Debug view shows a 4xx / 5xx row.
# Handling anime
Source: https://api.simkl.org/guides/anime
Learn how to sync and interact with Simkl's anime catalog. Whether your app is TMDB-primary or Anime-native, this guide covers the mapping, payloads, and edge cases.
Simkl uses AniDB as its primary metadata source for anime. Because the anime catalog is modeled differently than Western TV shows (often split per-cour or numbered absolutely), Simkl provides an automated cross-mapping system. This allows developers to interact with the anime catalog using standard TVDB/TMDB season-and-episode coordinates.
This guide outlines the two primary integration paths, how the cross-mapping works under the hood with real-world examples, and how to write watch history and read catalog data.
***
### Recommendation: Adopt Simkl's Native Anime Catalog
If you are designing a new application or have the flexibility to add a dedicated **Anime** content type to your existing app, we strongly recommend using **Simkl's Native Anime Catalog (Path B)** instead of treating anime as TV Shows.
**Why this is better:**
* **No Season Discrepancies:** Completely bypasses TMDB vs. TVDB season boundary disagreements.
* **1:1 Native Syncing:** Matches the data shape of major anime trackers (MyAnimeList, AniList, Kitsu, AniDB) for seamless 1:1 cross-platform syncing.
* **Rich Metadata:** Access to anime-specific metadata like animation studios, airing calendars, and comprehensive franchise relationships (prequels, sequels, summaries, spin-offs).
***
## The Two Integration Paths
Choose the path that fits your app's existing metadata system:
Your app tracks general TV and uses TMDB or TVDB IDs. You want to sync anime without changing your TV-centric seasons/episodes structure.
*Uses Simkl's automatic cross-mapping via the `use_tvdb_anime_seasons` flag.*
Your app is dedicated to anime and uses MAL, AniDB, AniList, or Kitsu IDs. You deal with flat/absolute episode numbers directly.
*Bypasses cross-mapping and interacts directly with Simkl's anime endpoints.*
***
## Integration Guide by App Type
Different app types require different integration strategies. Check the recommendations below to see which path and endpoints you should focus on:
**Recommendation:** Use **Path A** (TMDB/TVDB-Primary) for convenience, but see the best practices below for handling TMDB-primary boundary issues.
* **Syncing lists:** Send history updates using TMDB or TVDB IDs, always passing `"use_tvdb_anime_seasons": true`.
* **Fetching library:** Query `GET /sync/all-items/anime?extended=full_anime_seasons`. Use the `mapped_tvdb_seasons` array to know which seasons to render, and map the episodes back to your UI using `tvdb.season` and `tvdb.episode`.
* **Detail views:** Use [`GET /anime/{id}`](/api-reference/simkl/get-anime) to fetch details like `studios` and `relations` for anime shows.
**Recommendation:** Use **Path A** (TMDB/TVDB coordinates) for automated matching.
* **Real-time scrobbling:** Send playback state to [`POST /scrobble/start`](/api-reference/simkl/scrobble-start) using standard TVDB or TMDB IDs under the **`show`** key.
* **Why this is easy:** You do not need to convert TVDB season/episodes to absolute numbers client-side. Simkl's scrobbler handles the translation to absolute/cour-split anime entries automatically.
* **File-based scrobbling:** If your scrobbler parses absolute numbers directly from filenames, you can optionally scrobble using absolute numbers under the `anime` object.
**Recommendation:** Use **Path B** (Anime-Native IDs).
* **Syncing lists:** Send history updates using native database IDs (`mal`, `anilist`, `anidb`, `kitsu`) under the **`anime`** key.
* **Absolute numbering:** Send flat, absolute episode numbers directly. Do not supply season coordinates.
***
## Best Practices for TMDB-Primary Applications
Since **TMDB** is the most common metadata source for general media tracking applications, Simkl supports it fully. However, because TMDB's season/episode definitions occasionally disagree with TVDB's (which Simkl uses for mapping coordinates), you should use the following safe integration workflows:
### The Safe Integration Workflow
If your application's primary metadata source is TMDB, follow these rules to ensure perfect matching:
1. **Send Both IDs:** Whenever writing to Simkl, include both the `tmdb` ID and the `tvdb` ID in your `ids` object. Simkl will resolve the show using the most reliable record.
2. **Translate to TVDB Coordinates on Writes:** If you know TMDB and TVDB disagree on season boundaries for a specific anime, prioritize sending **TVDB season and episode coordinates** in the write payload when `use_tvdb_anime_seasons: true` is set.
3. **Use Simkl's Native IDs as Fallbacks:** If a show's TVDB/TMDB seasons are severely misaligned or fragmented, look up the show on Simkl using Title + Year or external anime IDs (MAL/AniList), and sync it using **Path B** instead.
***
## How the Cross-Mapping Works
Simkl stores a **per-episode TVDB coordinate** (`tvdb.season`, `tvdb.episode`) on every anime episode in the catalog. That coordinate maps directly from any TVDB- or TMDB-shaped `seasons[N].episodes[M]` payload to the exact Simkl record + episode the user means. The mapping handles every catalog shape uniformly.
### Real-World Mapping Examples
The main TV story is split into six separate Simkl titles (one per broadcast cour). Each title's episodes start at `1`, and carry the TVDB coordinate it maps to:
| Simkl cour-title | Eps | First episode → TVDB |
| ---------------------------------------------------------------------------------------------------------------- | --- | ----------------------- |
| [AOT S1](https://simkl.com/anime/39687/shingeki-no-kyojin) (`39687`, 2013) | 25 | Simkl ep 1 → TVDB S1E1 |
| [AOT Season 2](https://simkl.com/anime/439744/shingeki-no-kyojin-season-2) (`439744`, 2017) | 12 | Simkl ep 1 → TVDB S2E1 |
| [AOT Season 3 part 1](https://simkl.com/anime/694485/shingeki-no-kyojin-season-3) (`694485`, 2018) | 12 | Simkl ep 1 → TVDB S3E1 |
| [AOT Season 3 part 2](https://simkl.com/anime/931899/shingeki-no-kyojin-season-3) (`931899`, 2019) | 10 | Simkl ep 1 → TVDB S3E13 |
| [AOT Final Season](https://simkl.com/anime/1120029/shingeki-no-kyojin-the-final-season) (`1120029`, 2020) | 16 | Simkl ep 1 → TVDB S4E1 |
| [AOT Final Season part 2](https://simkl.com/anime/1579947/shingeki-no-kyojin-the-final-season) (`1579947`, 2022) | 12 | Simkl ep 1 → TVDB S4E17 |
All six share TVDB ID `267440` and TMDB ID `1429`. When you write to `Season 3 Episode 13`, Simkl automatically uses the episode offset mapping to route the write to episode `1` of *AOT Season 3 Part 2* (`931899`).
#### Franchise Extensions (OVAs, Specials, Movies)
AniDB models specials, OVAs, films, and spin-offs as separate entries, which Simkl mirrors. Below is the inventory beyond the main TV cours:
| Simkl title | Type | Eps | TVDB cross-ref | TMDB cross-ref |
| --------------------------------------------------------------------------------- | --------- | --- | -------------------- | -------------------------- |
| [AOT The Final Season special](https://simkl.com/anime/1883416) (`1883416`, 2023) | `special` | 2 | `267440` (parent TV) | `1429` (parent TV) |
| [AOT: The Last Attack](https://simkl.com/anime/2544548) (`2544548`, 2024) | `movie` | 1 | — | `1333100` (dedicated film) |
| [AOT OAD](https://simkl.com/anime/38688) (`38688`, 2013) | `ova` | 8 | `267440` (parent TV) | `1429` (parent TV) |
| [AOT: No Regrets](https://simkl.com/anime/419356) (`419356`, 2014) | `ova` | 2 | — | — |
| [AOT: Wings of Freedom](https://simkl.com/anime/49394) (`49394`, 2014) | `movie` | 2 | `7398` (dedicated) | `1429` (parent TV) ⚠️ |
| [AOT: Roar of Awakening](https://simkl.com/anime/732309) (`732309`, 2018) | `movie` | 1 | `13398` (dedicated) | `1429` (parent TV) ⚠️ |
| [AOT: Chronicle](https://simkl.com/anime/1358719) (`1358719`, 2020) | `movie` | 1 | `140480` (dedicated) | `1429` (parent TV) ⚠️ |
| [AOT: Junior High](https://simkl.com/anime/513950) (`513950`, 2015) | `tv` | 12 | `299882` (dedicated) | `63510` (dedicated TV) |
*Note: ⚠️ entries indicate parent-show fallback links on TMDB/TVDB instead of dedicated ones.*
One Piece is represented as a single Simkl title (`38636`) with 1100+ episodes, numbered absolutely. Every episode carries its TVDB season + episode coordinate:
| Simkl episode (absolute) | TVDB coordinate | Title |
| ------------------------ | --------------- | ------------------------------------------------------ |
| 1 | S1E1 | I'm Luffy! The Man Who's Gonna Be King of the Pirates! |
| 9 | S2E1 | The Honorable Liar? Captain Usopp! |
| 31 | S3E1 | The Worst Man in the Eastern Seas! |
| 48 | S4E1 | The Town of the Beginning and the End! |
| 196 | S10E1 | A State of Emergency Is Issued! |
| 326 | S12E1 | The Mysterious Band of Pirates! |
| 878 | S20E1 | The World Is Stunned! The Fifth Emperor of the Sea! |
| 1156 | S23E1 | The Long-Sought Elbaph! |
The single Simkl title shares TVDB ID `81797` and TMDB ID `37854`. Writing to TVDB season `10` episode `4` maps to absolute episode `199` on the single One Piece Simkl record.
***
## Path A: TMDB or TVDB Primary Integration
If your app uses TMDB or TVDB show IDs, you do not need to convert seasons/episodes to absolute numbers client-side. Simkl maps them automatically on both writes and reads.
### 1. Writing to Simkl (Syncing history/list additions)
When marking an episode as watched or adding a show to a list, send your TMDB/TVDB show ID along with standard seasons and episodes, and set **`use_tvdb_anime_seasons: true`**.
Under the hood, this flag instructs the Simkl API to fetch the mapped franchise seasons/titles and route the TVDB coordinates to the correct under-the-hood Simkl anime record and episode.
#### Example Payload
For **Attack on Titan Season 2 Episode 4** (using parent TV show TMDB ID `1429`):
```json POST /sync/history theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"shows": [
{
"ids": { "tmdb": "1429" },
"use_tvdb_anime_seasons": true,
"seasons": [
{
"number": 2,
"episodes": [{ "number": 4 }]
}
]
}
]
}
```
For **One Piece Season 10 Episode 4** (using TMDB ID `37854`):
```json POST /sync/history theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"shows": [
{
"ids": { "tmdb": "37854" },
"use_tvdb_anime_seasons": true,
"seasons": [
{
"number": 10,
"episodes": [{ "number": 4 }]
}
]
}
]
}
```
**Pro-tip:** You can set `use_tvdb_anime_seasons: true` on all TV shows in your payload. For non-anime shows, the flag is a safe no-op. For anime, it automatically handles both per-cour split titles and absolute-numbered shows.
### 2. Reading from Simkl (Syncing watchlist & watch history)
When reading the user's library, use **`extended=full_anime_seasons`**. This includes the TVDB/TMDB season cross-mapping in the response.
```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
curl "https://api.simkl.com/sync/all-items/anime?extended=full_anime_seasons&client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```
#### Understanding the Response Fields
For anime shows, the response includes:
* **`mapped_tvdb_seasons`**: An array of TVDB season numbers covered by this Simkl title. (e.g., `[2]` for *Attack on Titan Season 2*).
* **`tvdb: { season, episode }`**: Attached to each episode object to map the AniDB/absolute episode back to the TMDB/TVDB coordinates.
Example response snippet:
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"anime": [
{
"show": {
"title": "Shingeki no Kyojin Season 2",
"ids": { "simkl": 439744, "tmdb": "1429" }
},
"mapped_tvdb_seasons": [2],
"seasons": [
{
"number": 1,
"episodes": [
{
"number": 1,
"tvdb": { "season": 2, "episode": 1 }
}
]
}
]
}
]
}
```
### 3. Scrobbling Playback
For media players, scrobble payloads are single-item envelopes. You do **not** need to set `use_tvdb_anime_seasons`. Send the payload under the standard **`show`** key using your TMDB/TVDB coordinates, and Simkl will resolve the anime mapping automatically.
```json POST /scrobble/start theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"progress": 0,
"show": {
"ids": { "tmdb": "1429" }
},
"episode": {
"season": 2,
"number": 4
}
}
```
***
## Path B: Anime-Native Integration
If your app uses anime-native IDs (MAL, AniList, AniDB, Kitsu), you bypass Simkl's cross-mapping engine. External anime IDs have a 1:1 relationship with Simkl's anime records.
### 1. Writing to Simkl
Use the **`anime`** array envelope, and supply flat/absolute episode numbers under `episodes[]` (do not include a `season` field).
#### Example Payload: Cour-Split Anime (Attack on Titan S2 Ep 4)
```json POST /sync/history theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"anime": [
{
"ids": {
"mal": 25777,
"anilist": 20958,
"anidb": 10944
},
"episodes": [{ "number": 4 }]
}
]
}
```
#### Example Payload: Absolute-Numbered Anime (One Piece Absolute Ep 403)
```json POST /sync/history theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"anime": [
{
"ids": {
"mal": 21,
"anilist": 21,
"anidb": 69
},
"episodes": [{ "number": 403 }]
}
]
}
```
### 2. Reading from Simkl (Syncing watchlist & watch history)
When reading the user's library, perform a GET request to the anime-specific library endpoint:
```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
curl "https://api.simkl.com/sync/all-items/anime?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```
The response returns entries in the `anime` array, with all mapped external IDs (MAL, AniList, AniDB, Kitsu, etc.) populated in the `ids` block. You can map these IDs directly to your app's local database without any coordinate translation layers.
#### Example Response Snippet
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"anime": [
{
"added_to_watchlist_at": "2026-05-20T03:00:00-04:00",
"status": "watching",
"watched_episodes_count": 4,
"total_episodes_count": 12,
"anime": {
"title": "Shingeki no Kyojin Season 2",
"ids": {
"simkl": 439744,
"mal": "25777",
"anilist": "20958",
"anidb": "10944",
"kitsu": "8756"
}
}
}
]
}
```
***
## Anime-Specific Catalog Fields
When querying Simkl's catalog endpoints (e.g., [`GET /anime/{id}`](/api-reference/simkl/get-anime)), you will encounter fields unique to the anime catalog:
### 1. `anime_type`
Differentiates the format of the entry. Useful for client-side filtering:
| Type | Meaning |
| ------------- | ------------------------------------------ |
| `tv` | Standard broadcast television series |
| `movie` | Theatrical film |
| `ova` | Original Video Animation (direct-to-video) |
| `ona` | Original Net Animation (web-released) |
| `special` | Specials, OADs, side stories |
| `music video` | Music videos |
### 2. `relations[]`
Lists prequels, sequels, summaries, and side-stories.
* `relation_type`: Describes the relationship (e.g., `prequel`, `sequel`, `summary`, `side story`).
* `is_direct`: `true` for immediate narrative continuations; `false` for spin-offs or wider connections.
### 3. Flat Episode Lists
Unlike standard TV episode lists, [`GET /anime/episodes/{id}`](/api-reference/simkl/get-anime-episodes) returns a flat array of episodes. Standard episodes and specials alike omit the `season` field, carrying only `episode` (flat/absolute number) and a `tvdb` coordinate block.
***
## Anime-Specific Endpoints + Parameters Reference
### Writes
| Endpoint | Anime-specific behavior |
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [`POST /sync/history`](/api-reference/simkl/add-to-history) | Accepts `anime[]` envelope (alternative to `shows[]`). The `use_tvdb_anime_seasons: true` flag on a `shows[]` entry routes TVDB season/episode coordinates to the correct under-the-hood Simkl record via per-episode cross-mapping. |
| [`POST /sync/history/remove`](/api-reference/simkl/remove-from-history) | Same shape and flags as `/sync/history`. |
| [`POST /sync/add-to-list`](/api-reference/simkl/add-to-list) | Same shape and flags. Used to move anime into `watching` / `plantowatch` / `hold` / `completed` / `dropped`. |
| [`POST /scrobble/start`](/api-reference/simkl/scrobble-start) | Single-item envelope: `anime: { ids: {...} }` + `episode: { season, number }` (for TMDB/TVDB IDs) or `episode: { number }` (for anime-native IDs). Resolves the title and matches the episode through the same cross-map. |
### Reads — User Library
| Endpoint | Anime-specific behavior |
| --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`GET /sync/all-items/anime`](/api-reference/simkl/get-all-items) | Type-scoped library read — returns only the user's anime entries. |
| [`GET /sync/all-items?extended=full_anime_seasons`](/api-reference/simkl/get-all-items) | Adds `mapped_tvdb_seasons: [N, ...]` to anime show entries and adds a `tvdb: { season, episode }` cross-map block to every anime episode in the response. |
| [`GET /sync/all-items?anime_type=tv`](/api-reference/simkl/get-all-items) | Filter anime entries by `anime_type` (`tv`, `movie`, `ova`, `ona`, `special`, `music video`). |
| [`GET /sync/activities`](/api-reference/simkl/get-activities) | Returns an `anime` block separate from `tv_shows` and `movies`, each with its own per-status timestamps. |
### Reads — Catalog
| Endpoint | Anime-specific behavior |
| --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`GET /anime/{id}`](/api-reference/simkl/get-anime) | Anime detail. Returns: `anime_type`, `mapped_tvdb_seasons[]`, `relations[]`, `en_title`, `alt_titles[]`, `season_name_year`, and `studios`. (Does not include the episode list). |
| [`GET /anime/episodes/{id}`](/api-reference/simkl/get-anime-episodes) | Returns flat episode array (no `season` fields; `episode` number is 1-N). Includes `tvdb: { season, episode }` blocks on each episode. |
| [`GET /search/anime`](/api-reference/simkl/search-by-text) | Text search scoped to anime catalog. Returns `anime_type`, `title_romaji`, and `title_en`. |
***
## Integration Edge Cases
Because TMDB and TVDB are community-maintained, they occasionally disagree on season numbering for newer or split-cour anime (e.g., *Solo Leveling*).
Since Simkl's cross-mapping is based on **TVDB season coordinates**, sending TMDB-only IDs with TMDB-style season numbers can sometimes route to the wrong cour.
**Solutions:**
1. **Send the TVDB ID** (`ids.tvdb`) alongside the TMDB ID, using the TVDB season numbering in your payload.
2. **Provide anime-native IDs** (`mal`, `anilist`, `anidb`) if your app has access to them, which maps 1:1 and avoids the translation step.
3. **Send Title + Year** (`title` and `year`). Simkl's server-side title resolver is robust and resolves directly to the right cour.
OVAs, specials, and recap films often inherit the parent TV show's TMDB or TVDB ID on Simkl because a dedicated film entry does not exist or wasn't linked.
* **Implied Action:** Do not assume the Simkl `tmdb` / `tvdb` ID maps 1:1 to a unique external entry. To deep-link to external catalogs, treat these IDs as references rather than canonical links.
Some multi-part film series are grouped as a single Simkl entry with multiple episodes (each representing a film part).
Check the `total_episodes` field to determine if a `movie` entry contains multiple parts.
***
## Next Steps
`POST /sync/history` — payload shape, batching, mixing movies / shows / anime in one call.
Initial sync + continuous deltas, `/sync/activities` gating, `extended=full_anime_seasons`, supported IDs, write operations, edge cases.
Real-time start / pause / stop / checkin. Includes anime-episode-numbering examples for both anime-native and TMDB / TVDB-shaped scrobbles.
The `ids` table, `anime[]` vs `shows[]` envelope rule, and how episode objects work across every endpoint.
# Custom Lists
Source: https://api.simkl.org/guides/custom-lists
How to access user-curated Custom Lists from simkl.com/lists/ — and what to use today while the V2 Beta API ships.
**Custom Lists are user-curated collections of titles** — themed groupings like *"Best 90s sci-fi"*, *"Watched with my kids"*, or *"Recommended by Anna"* — created by users in the simkl.com web UI at [simkl.com/lists/](https://simkl.com/lists/).
They are **distinct from the [five-status Watchlist](/conventions/list-statuses)** (`watching`, `plantowatch`, `hold`, `dropped`, `completed`) that the API has always exposed. A title can be on the user's Watchlist with a status AND on one or more Custom Lists at the same time, independently.
**Custom Lists are not yet available via the API.** They're served by **Simkl V2 Beta**, an in-development backend that isn't publicly callable yet. There is no public endpoint to read, create, or modify Custom Lists, and no firm ETA.
Until V2 Beta ships, use the existing **Watchlist sync** endpoints (below) for everything library-related. We'll announce Custom Lists on the [changelog](/changelog) and in the [Discord](https://discord.gg/MJsWNE4) `#api` channel the moment they're callable.
## What to use today
For "fetch the user's library" use cases, the Watchlist sync endpoints already cover what most apps need — every movie / show / anime the user has tracked, with status, ratings, watched-episode counts, last-watched timestamps, and rewatch sessions.
Full library snapshot. See the [Sync guide](/guides/sync) for two-phase fetch and `date_from` semantics.
Move items between the five Watchlist statuses.
Per-bucket timestamps for cheap "is there anything new?" polling.
Record watch events — single episode, whole season, or whole show.
These give you the same library data Simkl shows at `simkl.com//` — organized by status bucket rather than by user-defined list, but the underlying items are the same.
## When V2 Beta ships
When Custom Lists become available via the public API, we'll publish endpoints to:
* **List** the user's Custom Lists with metadata (name, description, public/private, item count).
* **Read** a specific Custom List's items.
* **Create**, **update**, and **delete** Custom Lists.
* **Add** and **remove** items within a Custom List.
* Read **public** Custom Lists by user or list slug — the same lists currently visible in the web UI at `simkl.com//lists/`.
The Watchlist sync endpoints will continue to work unchanged — Custom Lists are a new layer on top, not a replacement.
## Common questions
The web UI calls internal endpoints that are part of Simkl's V2 Beta backend. They aren't documented or stable, and they're tied to Simkl's own session cookies rather than OAuth tokens. **Don't scrape them** — they'll break under you without notice, and we may rate-limit or block clients that hit them. Wait for the public API.
Not on the Simkl side. The only grouping primitive the public API exposes today is the five-status Watchlist. If your app needs custom grouping right now, store the grouping client-side (your own DB keyed by Simkl ID) and overlay it on top of `/sync/all-items` data. When the Custom Lists API ships, you'll be able to migrate that data into real Simkl Custom Lists.
Yes. The Watchlist endpoints (`/sync/all-items`, `/sync/add-to-list`, `/sync/history`, …) will continue to work unchanged. Custom Lists are a separate, additive concept — a title can be on the user's Watchlist with a status (e.g. `completed`) AND on one or more Custom Lists at the same time, independently.
Public Custom Lists are viewable in the web UI at `simkl.com//lists/`, but there's no API for them yet. Public-list read access is planned to ship alongside the rest of the Custom Lists API.
Once shipped, yes — they'll use the same rate-limit pool as the other `/sync/*` endpoints. Plan your polling accordingly: a Custom-Lists-heavy app should batch reads where possible and use `/sync/activities` (or its eventual Custom-Lists equivalent) to detect change rather than polling list contents on a fixed cadence.
## Reference
Two-phase model, `date_from` semantics, deletion reconciliation.
The five status values, per-type rules, and the legacy `notinteresting` → `dropped` rename.
Simple "the user finished this" recipes.
Separate sessions for items the user already finished.
# Deep-linking to Simkl
Source: https://api.simkl.org/guides/deep-linking
Link from your app, newsletter, or extension straight to the right Simkl page — even when you don't have a Simkl ID. Use external IDs (IMDb, TMDB, MAL, …) or title + year as the entry point.
If your app already has external IDs (IMDb, TMDB, TVDB, AniDB, …) — typical for watchlist importers, media-server plugins (Plex / Jellyfin / Kodi), browser extensions, and any app that maintains its own catalog of titles — you don't need to call the Simkl JSON API just to send a user to the matching Simkl page. The `GET /redirect` endpoint takes any identifier you have and 301-redirects to the right URL on simkl.com.
**No `Authorization` token required.** No JSON parsing. The browser (or any HTTP client) follows the redirect automatically.
Two use cases (linking + ID resolution), every accepted parameter, all action modes (`to=simkl`/`trailer`/`twitter`/`watched`), curl/JS/Python recipes for resolving an external ID into a Simkl ID for follow-up API calls.
## The simplest case — just use the URL as a hyperlink
In a newsletter, a browser-extension menu, a "View on Simkl" button, or any other surface where you want a clickable link, **the `/redirect` URL is the href.** The browser hits it, gets a 301, follows it to simkl.com. No JS, no fetch, no parsing.
```html theme={"theme":{"light":"github-light","dark":"vesper"}}
View Game of Thrones on Simkl
```
That's the entire integration. Just remember to URL-encode any query-string values that need it (titles with spaces, special characters).
## Recipes by what you have
**Pass every identifier you have — not just the cheapest one.** `/redirect` resolves the best match by considering all the data you send. Combining an ID with `title` and `year` is more reliable than the ID alone: if a catalog ever re-issues or merges an ID, the title and year keep your link pointing at the right item. The examples below show realistic combined payloads, not minimum-required calls.
```bash IMDb + title + year → Simkl page theme={"theme":{"light":"github-light","dark":"vesper"}}
# IMDb IDs are unambiguous across types (the `tt` prefix is unique), so no `type` needed.
# Title and year are extra signal that helps when an ID is ambiguous or stale.
curl -I "https://api.simkl.com/redirect?to=simkl\
&imdb=tt0944947&title=Game%20of%20Thrones&year=2011\
&client_id=$CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
# Response:
# HTTP/1.1 301 Moved Permanently
# Location: https://simkl.com/tv/17465/game-of-thrones
```
```bash TMDB (TV) + title + year → Simkl page theme={"theme":{"light":"github-light","dark":"vesper"}}
# TMDB IDs are NOT unique across types — a TMDB movie ID and TMDB TV ID can collide.
# Always pair TMDB with `type=movie` or `type=tv` — TMDB has no anime type
# (anime shows are filed under tv on TMDB; Simkl routes them to its anime catalog automatically).
curl -I "https://api.simkl.com/redirect?to=simkl\
&type=tv&tmdb=1399&title=Game%20of%20Thrones&year=2011\
&client_id=$CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
# Location: https://simkl.com/tv/17465/game-of-thrones
```
```bash TMDB (movie) + title + year → Simkl page theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -I "https://api.simkl.com/redirect?to=simkl\
&type=movie&tmdb=27205&title=Inception&year=2010\
&client_id=$CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
# Location: https://simkl.com/movies/472214/inception
```
```bash MAL + title + year → Simkl anime page theme={"theme":{"light":"github-light","dark":"vesper"}}
# Anime catalog IDs (MAL, AniDB, AniList, Kitsu, AniSearch, LiveChart, Anime-Planet)
# don't need `type` — they're unambiguous.
curl -I "https://api.simkl.com/redirect?to=simkl\
&mal=11757&title=Sword%20Art%20Online&year=2012\
&client_id=$CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
# Location: https://simkl.com/anime/37226/sword-art-online
```
```bash Multiple IDs at once → most reliable theme={"theme":{"light":"github-light","dark":"vesper"}}
# When your local catalog stores several external IDs per title (common for
# apps consolidating data from multiple databases), pass them all.
curl -I "https://api.simkl.com/redirect?to=simkl\
&imdb=tt0903747&tmdb=1396&tvdb=81189\
&title=Breaking%20Bad&year=2008\
&client_id=$CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
# Location: https://simkl.com/tv/11121/breaking-bad
```
```bash Title + year only → fallback when you have no IDs theme={"theme":{"light":"github-light","dark":"vesper"}}
# Useful for free-text catalogs or rare items not in mainstream databases.
# Always pair with `type` to disambiguate movies from same-titled shows.
curl -I "https://api.simkl.com/redirect?to=simkl\
&type=movie&title=Inception&year=2010\
&client_id=$CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
# Location: https://simkl.com/movies/472214/inception
```
```bash Specific episode → Simkl episode page theme={"theme":{"light":"github-light","dark":"vesper"}}
# Add `season=N&episode=M` to any of the above. Setting either parameter
# excludes movies from the candidate set automatically.
curl -I "https://api.simkl.com/redirect?to=simkl\
&imdb=tt0944947&title=Game%20of%20Thrones&year=2011\
&season=1&episode=3\
&client_id=$CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
# Location: https://simkl.com/tv/17465/game-of-thrones/season-1/episode-3
```
## Action modes — what should clicking the link do?
The `to=` parameter controls what the redirect lands on. Default is `simkl` (the item's detail page).
| `to=` value | Where the user lands |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `simkl` *(default)* | The Simkl page for the item (or season / episode if you also passed `season`/`episode`). |
| `trailer` | The trailer URL (typically YouTube). Great for "Watch trailer" buttons. |
| `twitter` | A pre-filled tweet (`twitter.com/intent/tweet?...`) with the title and a Simkl link. Use for "Share on Twitter" / "Share on X" buttons. |
| `watched` | Marks the item as watched on the user's account, then lands them on the page. If the user isn't signed in, Simkl first redirects through `/oauth/authorize`, completes the action, then lands them on the page. |
```html theme={"theme":{"light":"github-light","dark":"vesper"}}
▶ Watch trailer
Share on X
✓ Mark as watched on Simkl
```
## When you already have a Simkl ID
If you've already called `/movies/:id`, `/tv/:id`, `/anime/:id`, or any sync endpoint, the response includes `ids.simkl_id` (or `ids.simkl`) and `ids.slug` on every item. **Build the URL yourself** — it's faster than another `/redirect` round-trip:
| Resource | URL pattern |
| ------------- | --------------------------------------------------------------- |
| Movie | `https://simkl.com/movies/{simkl_id}/{slug}` |
| TV show | `https://simkl.com/tv/{simkl_id}/{slug}` |
| Anime | `https://simkl.com/anime/{simkl_id}/{slug}` |
| TV season | `https://simkl.com/tv/{simkl_id}/{slug}/season-{N}` |
| TV episode | `https://simkl.com/tv/{simkl_id}/{slug}/season-{N}/episode-{M}` |
| Anime episode | `https://simkl.com/anime/{simkl_id}/{slug}/episode-{M}` |
| User profile | `https://simkl.com/{username}` |
| User stats | `https://simkl.com/{username}/stats/` |
| User library | `https://simkl.com/{username}/{tv\|movies\|anime}/` |
**Always include the slug** when you have it. The slug is technically optional, but skipping it forces Simkl to run a title lookup and 301-redirect to the slugged URL anyway — that's a wasted round-trip on both sides. The slug is returned in `ids.slug` on every standard media object — store it alongside the Simkl ID and reuse.
## Need the Simkl ID for an API call?
`/redirect` is also the **cheapest way** to resolve an external ID into a Simkl ID — much lighter than `GET /search/id`. Issue a HEAD request, parse the `Location` header for the numeric ID, then call `/movies/{id}` / `/tv/{id}` / `/anime/{id}` to get the full record. The follow-up call is Cloudflare-cached by Simkl ID, so repeat lookups of the same title cost almost nothing.
Step-by-step recipes in bash, JS, and Python for resolving an external ID into a Simkl ID via `/redirect`'s `Location` header, then fetching the full record from the cached detail endpoint. Includes the regex for extracting the ID and a caching recommendation.
## Caching links
The `/redirect` response always returns `Cache-Control: no-store`, so don't try to HTTP-cache the redirect itself. But **the resolved URL** (everything after the 301) doesn't change unless a Simkl admin re-slugs the title — extremely rare. If your app does the resolution once and renders many links to the same item, cache the result locally and skip the redirect for the next render.
## Required parameters
Like every Simkl endpoint, requests to `/redirect` must include `client_id`, `app-name`, `app-version` as URL parameters and a `User-Agent` header. See [Headers and required parameters](/conventions/headers#required-url-parameters) for the full convention.
`/redirect` does **not** require an `Authorization` token — except for `to=watched`, which signs the user in if they aren't already.
## Reference
The full API reference — every accepted parameter, all action modes, and recipes for the advanced "external ID → Simkl ID → detail-endpoint call" workflow.
Endpoint reference with the parameter schema and interactive playground.
Where the `ids.simkl_id` and `ids.slug` fields come from. You'll see them on every API response.
The complete list of external catalog identifiers and slugs you can pass to `/redirect`, with format examples for each.
# Mark as watched
Source: https://api.simkl.org/guides/mark-as-watched
The fastest way to record that a user finished a movie, episode, or season — without scrobbling.
If you just want to record that a user **finished** something — a movie, an episode, a whole season — use a single [`POST /sync/history`](/api-reference/simkl/add-to-history) call. You don't need scrobbling, and you don't need a media player.
**Use scrobble only if you're tracking real-time playback** (a media-server plugin, a video player). If you just want a "Mark as watched" button, this page is what you need.
**`POST /sync/history` already moves the item to the right Watchlist — don't follow up with `/sync/add-to-list`.** The history call places (and re-classifies) the item based on the watch event. Read `added.statuses[].response.status` in the response to see where it landed: a `"completed"` write on a still-airing show is silently downgraded to `"watching"`, and that resolved status is what the server stored. A follow-up `/sync/add-to-list` call would overwrite the server's smarter decision.
## Endpoints used on this page
Mark one or many items as watched. The main endpoint for this guide.
Undo a mark-as-watched. Same payload shape.
For media players: report that playback has begun.
For media players: stop playback. ≥80% progress = marked watched.
## Mark a movie
```bash curl theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST "https://api.simkl.com/sync/history?client_id=$CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "User-Agent: my-app-name/1.0" \
-d '{
"movies": [
{ "ids": { "imdb": "tt1201607" } }
]
}'
```
```js JavaScript theme={"theme":{"light":"github-light","dark":"vesper"}}
const params = new URLSearchParams({
client_id: clientId,
'app-name': 'my-app-name',
'app-version': '1.0',
});
await fetch(`https://api.simkl.com/sync/history?${params}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'User-Agent': 'my-app-name/1.0',
},
body: JSON.stringify({
movies: [{ ids: { imdb: 'tt1201607' } }],
}),
});
```
```python Python theme={"theme":{"light":"github-light","dark":"vesper"}}
import requests
requests.post(
'https://api.simkl.com/sync/history',
params={
'client_id': client_id,
'app-name': 'my-app-name',
'app-version': '1.0',
},
headers={
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json',
'User-Agent': 'my-app-name/1.0',
},
json={'movies': [{'ids': {'imdb': 'tt1201607'}}]},
)
```
You can pass any ID Simkl recognizes — see [Supported ID keys](/conventions/standard-media-objects#supported-id-keys) for the full list with types and examples. Full payload reference at [`POST /sync/history`](/api-reference/simkl/add-to-history).
**User doesn't remember when they watched it?** POST `"watched_at": "1970-01-01T00:00:01Z"` — Simkl's *"Very long time ago / I don't remember"* placeholder. simkl.com's "When did you watch this?" date picker exposes this as a smart-suggestion card; clients should mirror that option. Full convention (including detection rule for reading and recommended date-picker UX patterns) at [Dates and timezones → "Very long time ago" placeholder](/conventions/dates#very-long-time-ago).
**Send every identifier you have — `title`, `year`, and the full `ids` object.**
Simkl walks the `ids` object in priority order (`simkl` first when present, then external IDs like `imdb`, `tmdb`, `tvdb`, `mal`, `anidb`, …). If no ID resolves, it falls back to a `title` + `year` match, then to title-only as a last resort. Sending everything you know — title, year, plus every external ID your client has cached — maximizes the chance the right item gets credited. Extra fields are free; missing fields can cause a `404 id_err` or, worse, a silent mismatch.
**You don't need to search before writing.** Endpoints like `/scrobble/*`, `/sync/history`, `/sync/add-to-list`, and `/sync/ratings` resolve IDs server-side — pass whatever you have directly, no `/search/*` round-trip required. Calling `/search/id` first to "resolve" a Simkl ID is wasted work that doubles your API quota for no gain.
See [Supported ID keys](/conventions/standard-media-objects#supported-id-keys) for the full list.
## Mark an episode
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"shows": [
{
"ids": { "tmdb": "1399" },
"seasons": [
{
"number": 1,
"episodes": [{ "number": 1 }]
}
]
}
]
}
```
## Mark a whole season
Drop the `episodes` array — Simkl marks every episode in the listed season:
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"shows": [
{
"ids": { "tmdb": "1399" },
"seasons": [{ "number": 1 }]
}
]
}
```
## Mark a whole show
Drop both `seasons` and `episodes`:
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"shows": [
{ "ids": { "tmdb": "1399" } }
]
}
```
## Mix everything in one call
Movies, shows, and anime can be sent together. Useful for importing watch history from another tracker. Anime entries are equally valid under either `shows[]` or `anime[]` — Simkl resolves the catalog by `ids` either way (see [Anime in `shows[]` or `anime[]`](/conventions/standard-media-objects#anime)).
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"movies": [
{ "ids": { "imdb": "tt1201607" } },
{ "ids": { "tmdb": "12445" } }
],
"shows": [
{ "ids": { "tvdb": "121361" } }
],
"anime": [
{ "ids": { "mal": "4246" } }
]
}
```
## When to use which endpoint
[`POST /sync/history`](/api-reference/simkl/add-to-history) — instant, one call. Best for "Mark watched" buttons, importers, manual logging.
Real-time playback tracking. Best for media players (Plex, Jellyfin, custom apps) that report actual user events.
**Scrobble does mark the item as watched, but only at the end of playback** — either when you call [`POST /scrobble/stop`](/api-reference/simkl/scrobble-stop) with ≥ 80% progress, or when [`POST /scrobble/checkin`](/api-reference/simkl/scrobble-checkin) auto-completes at 100% based on the item's runtime. **[`POST /scrobble/start`](/api-reference/simkl/scrobble-start) alone does *not* mark anything watched** — it only puts the title in the user's "Watching now" banner. If you're already scrobbling, you don't also need to call `/sync/history`.
## Record a rewatch (Simkl Pro / VIP)
A `POST /sync/history` call on an already-Completed item is normally a no-op. Pass `?allow_rewatch=yes` to record an additional viewing as a separate rewatch session — but **read the full [Rewatches guide](/guides/rewatches) first**. The flag opens a parallel write path that, used carelessly, pollutes the user's history stats and rewatches panel with phantom sessions. The guide covers session lifecycle (`active` / `completed` / `closed`), per-item fields (`rewatch_id`, `is_rewatch`, …), episode-level tracking on shows, reading sessions back from `GET /sync/all-items`, UI patterns simkl.com uses, and — critically — the precautions you have to implement before enabling the flag.
```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST "https://api.simkl.com/sync/history?allow_rewatch=yes&client_id=$CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "User-Agent: my-app-name/1.0" \
-d '{ "movies": [{ "ids": { "imdb": "tt1201607" }, "watched_at": "2026-05-10T20:00:00Z" }] }'
```
## Removing items
Made a mistake? Remove with the same payload shape, just hit [`POST /sync/history/remove`](/api-reference/simkl/remove-from-history):
```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST "https://api.simkl.com/sync/history/remove?client_id=$CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "User-Agent: my-app-name/1.0" \
-d '{ "movies": [{ "ids": { "imdb": "tt1201607" } }] }'
```
**A few `POST /sync/history` behaviors worth knowing:**
* **No `seasons` / `episodes` and no `status` for a show.** Simkl picks based on the show's airing status: finished airing → **Completed** with every episode marked; currently airing → **Watching** with all *already-aired* episodes marked (future episodes left untouched); not yet released → **Watching** with no episodes marked.
* **Set status without touching episodes.** Pass `status: "watching"` (or any other status) on the show to move the watchlist bucket without auto-marking episodes.
* **Episode-level `ids` override `number`.** When an episode object includes `ids.tvdb` or `ids.anidb`, those IDs take precedence over the `number` field for matching — useful for absolute-order anime numbering or specials whose episode numbers don't line up across sources. That said, **prefer `season` + `number` when you have them** — episode IDs can be re-issued when catalogs merge or re-number, while `S1E4` is stable forever (see [Episode IDs](/conventions/standard-media-objects#episode)).
* **Skip the auto-fill.** Add `?skip_auto_watching=yes` to suppress the auto-mark behavior for shows posted without explicit episodes. Only takes effect when the request also sets an explicit `status` on the show.
# Rewatches
Source: https://api.simkl.org/guides/rewatches
Track separate viewing sessions for items the user already finished — mirror what simkl.com displays on a movie / show / anime detail page.
A **rewatch** is a separate viewing session for an item the user already completed. Each session has its own start date, episode progress (for shows / anime), and status (`active`, `completed`, or `closed`). Up to 50 sessions per item. Simkl returns the full session list so you can replicate the simkl.com "Rewatches" panel.
**Read this guide before enabling `?allow_rewatch=yes` in production.** Wired up carelessly — on retries, scrobble events, importer re-runs, or without pinning `rewatch_id` after the first write — the flag pollutes the user's history stats and rewatches panel with phantom sessions. Before flipping it on:
1. **Pin `rewatch_id`** on every write after the first. Without it, sessions can fork silently.
2. **Gate `?allow_rewatch=yes` behind explicit user intent** — a dedicated "Rewatch" button. Never on background syncs, scrobble auto-events, importer re-runs, or retries.
3. **Default off in your app settings.** Expose a per-user *"Track rewatches"* toggle — many users prefer plain "mark watched" without session bookkeeping.
4. **Test the full lifecycle** (start → episodes → close → reactivate → complete) on a real account before shipping.
If you just need to mark watched, plain `POST /sync/history` is what you want — see [Mark as watched](/guides/mark-as-watched).
**Simkl Pro / VIP only.** Free-tier callers with `?allow_rewatch=yes` get a silent no-op — the request still consumes a rate-limit slot but returns `added: { movies: 0, shows: 0, episodes: 0 }`. **Gate the flag behind the user's plan instead of sending it blindly:**
1. **On sign-in (or first launch)**, call [`POST /users/settings`](/api-reference/simkl/get-user-settings) once and read `account.type` — one of `"free"`, `"pro"`, or `"vip"`. Cache it locally.
2. **Don't refetch on every poll.** User settings are set-and-forget. Watch `activities.settings.all` from [`GET /sync/activities`](/api-reference/simkl/get-activities) (you're already polling it for the sync delta — see the [Sync guide](/guides/sync)); when that timestamp bumps, refetch `/users/settings` once to pick up plan upgrades, downgrades, timezone changes, etc.
3. **If `account.type === "free"`**, hide or disable the "Rewatch" button with a short note like *"Rewatch tracking requires Simkl Pro or VIP"* and link to [simkl.com/vip](https://simkl.com/vip/). Don't send `?allow_rewatch=yes` for free-tier users — there's no value in firing a request that the server will silently drop.
## Endpoints used
Write watch events. `?allow_rewatch=yes` makes the write land in a rewatch session instead of the canonical row.
Read sessions back. `?allow_rewatch=yes` adds one extra row per saved session alongside the canonical entry.
## Quick start
**A movie rewatch:**
```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST "https://api.simkl.com/sync/history?allow_rewatch=yes&client_id=$CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "User-Agent: my-app-name/1.0" \
-d '{ "movies": [{ "ids": { "simkl": 53536 }, "watched_at": "2026-05-10T20:00:00Z" }] }'
```
**A TV episode rewatch — first call creates the session:**
```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
POST /sync/history?allow_rewatch=yes
{ "shows": [{ "ids": { "simkl": 1234 }, "seasons": [{ "number": 1, "episodes": [{ "number": 1, "watched_at": "2026-05-10T20:00:00Z" }] }] }] }
```
Response echoes `rewatch_id` and `rewatch_status` inside `added.statuses[].response`. **Cache the `rewatch_id` and pin it on every subsequent write** for that session.
## When does a write become a rewatch?
The server always tries the regular write first. If the regular write would actually mark new data, that takes precedence — no rewatch is created. A write lands in a rewatch session when **any** of these holds:
1. **The item is already Completed and the regular add is a no-op** (movies: user already finished it; shows: payload has no episodes and the show is Completed).
2. **You included episodes and every one is already on the user's history** — i.e. the regular write would change nothing.
3. **You passed `is_rewatch: true`** on the item.
`is_rewatch: true` does **not** bypass the regular-first logic. If the write tries to add a date that already exists in canonical history, it's still rejected. The flag's main use is to create an empty rewatch session on a Completed item your UI explicitly marked as *"starting a rewatch"* — without writing any episodes yet.
The episode or movie must already exist on the user's history (`completed` / `watching` / `hold` / `dropped`) — Plan-to-Watch entries can't be rewatched, there's nothing to rewatch.
## Per-item rewatch fields
**All fields below are optional.** They only take effect when `?allow_rewatch=yes` is on the URL.
| Field | Type | Purpose |
| ----------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `is_rewatch` | bool | Trigger rewatch logic explicitly. See [When does a write become a rewatch?](#when-does-a-write-become-a-rewatch) for what overrides what. |
| `rewatch_id` | int | Resume a specific session. Use the value from a previous response. Without it, the server picks the item's active session if one exists, else creates a new session. |
| `rewatch_status` | string | Explicitly set session state — `active`, `closed`, or (movies only) `completed`. See [Session states](#session-states) for the full state machine. |
| `watched_at` | string (ISO-8601) | Standard per-item timestamp. Also serves as the session's start date. |
| `last_watched_at` | string (ISO-8601) | Primarily a **response** field from `GET /sync/all-items` (the session's most recent watch event). Rarely set on writes. |
## Session states
A session is in exactly one of three states. For TV / anime, `completed` is **server-earned only**: the server promotes a session when the rewatch covers every aired regular episode (specials in season 0 don't count). For movies, `completed` is the default and only meaningful state.
| State | Meaning |
| ----------- | -------------------------------------------------------------------------- |
| `active` | In progress. Exactly one per item — starting another auto-closes this one. |
| `closed` | Explicitly ended by the user. Resumable. Keeps `watched_episodes_count`. |
| `completed` | All aired regular episodes covered (TV / anime); or the movie's default. |
**Transitions you can drive from a write** (`POST /sync/history?allow_rewatch=yes` + `rewatch_status: "..."`):
* `"active"` — start or reactivate a session. The previously-active session for the item auto-closes.
* `"closed"` — end the active session. No-op against a `completed` session; reactivate it first if you really need to close it.
* `"completed"` — honored for movies. **For TV / anime, silently clamped to `"closed"`** because only coverage earns DONE. Subsequent episode writes that complete coverage will promote the session back to `"completed"`.
**Transitions the server runs automatically:** any non-`completed` session (whether `active` or `closed`) auto-promotes to `completed` the moment an episode write pushes coverage to every aired regular episode.
## The 2-day gap
Any two watch events on the **same item** (movie or individual episode) must be at least 2 days apart, or the second write collapses into the first session. *It's a rewatch, not a rewind* 😄.
The gap is **per-item**, not per-show. Watching S1E1 on Monday and S1E2 on Tuesday is fine — those are different items. The rule only fires when the *same* episode is written twice within 48h.
**Why a 2-day gap?** It absorbs common timestamping problems that would otherwise inflate the user's history with phantom rewatches:
* **Sleep-and-resume** — user starts an episode at night, finishes the next morning. One viewing, not two.
* **Wrong timezones** — clients labeling local time as UTC (or vice versa) drift 1–12 hours per write.
* **DST transitions** — naive date libraries miscalculate by an hour twice a year.
* **Multi-device duplicates** — phone + TV both report the same play.
* **Retry storms** — flaky connections retry a write that already landed.
* **Importer re-runs** — re-running an import re-sends the same `watched_at`.
* **Scrobble pause/resume noise** — bathroom break → re-fire of `/start` + `/stop`.
* **Buggy progress reporting** — players hitting 80%+ multiple times on seeks.
Real rewatches happen days, weeks, or months apart — not within hours. The 48-hour rule encodes that.
## Reading sessions back
`?allow_rewatch=yes` on `GET /sync/all-items` adds one extra entry per saved rewatch session alongside the canonical entry for each item. **Always pair with `date_from`** — never call without it outside the initial Phase-1 sync.
**`allow_rewatch=yes` alone returns summary-only rewatch rows.** `watched_episodes_count` returns `0` as a sentinel, `last_watched` is `null`, and `seasons[].episodes[]` is absent — even when the session actually has episodes recorded. To get per-episode data on rewatch rows you **must** add `extended=full` (and usually `episode_watched_at=yes` for timestamps):
```
GET /sync/all-items/all/completed?allow_rewatch=yes&extended=full&episode_watched_at=yes
```
Apps that mirror simkl.com's "Rewatches" panel — or that need to apply per-episode diffs from rewatch sessions to a local cache — should always use this flag combo. The [Flag combinations table](#flag-combinations-for-episode-lists) below has the full set.
### Canonical vs rewatch rows
| Field | Canonical (`is_rewatch: false`) | Rewatch (`is_rewatch: true`) |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| `is_rewatch` | `false` *(emitted when `?allow_rewatch=yes` is set; otherwise absent)* | `true` |
| `rewatch_id` | absent | session id |
| `rewatch_status` | absent | `active` / `completed` / `closed` |
| `last_watched_at` | The canonical (original) last watch date for the item — **does not** reflect rewatch sessions. | This session's most-recent watch event. |
| `watched_episodes_count` | Episodes marked watched on the canonical row (e.g. `62/62` for a finished show). **Not** a lifetime total across rewatches. | Per-session count — **`0` as a sentinel without `extended=full`**. |
The canonical row and the rewatch rows are independent: `last_watched_at` and `watched_episodes_count` on the canonical entry never incorporate rewatch progress, no matter how many sessions exist for the item.
### Worked example — Game of Thrones with one rewatch session
A representative shape from `GET /sync/all-items?allow_rewatch=yes&extended=full&episode_watched_at=yes` — Game of Thrones (Simkl ID `17465`) finished long ago, currently being rewatched in progress (4 of 73 episodes into S1). The item appears **twice** in `shows[]`: once as the canonical row, once as the in-progress rewatch row.
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"shows": [
{
"added_to_watchlist_at": "2026-05-15T00:13:18Z",
"last_watched_at": "2026-05-15T00:13:18Z",
"user_rated_at": "2026-05-16T14:20:16Z",
"user_rating": 10,
"status": "completed",
"last_watched": null,
"next_to_watch": "S01E01",
"watched_episodes_count": 73,
"total_episodes_count": 73,
"not_aired_episodes_count": 0,
"show": {
"title": "Game of Thrones",
"poster": "57/5742576cd8f59fcb0",
"year": 2011,
"runtime": 52,
"ids": {
"simkl": 17465,
"slug": "game-of-thrones",
"imdb": "tt0944947",
"tvdbslug": "game-of-thrones",
"traktslug": "game-of-thrones",
"tvdb": "121361",
"tmdb": "1399"
}
},
"is_rewatch": false
},
{
"added_to_watchlist_at": "2026-05-16T14:02:10Z",
"last_watched_at": "2026-05-16T20:00:00Z",
"user_rated_at": "2026-05-16T14:20:16Z",
"user_rating": 10,
"status": "completed",
"last_watched": "S01E04",
"next_to_watch": "S01E05",
"watched_episodes_count": 4,
"total_episodes_count": 73,
"not_aired_episodes_count": 0,
"show": { "title": "Game of Thrones", "ids": { "simkl": 17465, "slug": "game-of-thrones" /* ... */ } },
"is_rewatch": true,
"rewatch_id": 7482,
"rewatch_status": "active",
"seasons": [
{
"number": 1,
"episodes": [
{ "number": 1, "watched_at": "2026-05-16T14:02:10Z" },
{ "number": 2, "watched_at": "2026-05-16T14:02:10Z" },
{ "number": 3, "watched_at": "2026-05-16T19:00:00Z" },
{ "number": 4, "watched_at": "2026-05-16T20:00:00Z" }
]
}
]
}
]
}
```
A few things to notice in the example above:
* **Same `simkl` ID on both rows** — `17465` appears twice. Group by `ids.simkl` when iterating; treat `is_rewatch: true` rows as side-cars on the canonical entry.
* **Canonical `watched_episodes_count`** is `73/73` (the user finished the show originally); the rewatch row's `4/73` is per-session and does NOT advance the canonical count.
* **`last_watched_at` is independent** on each row — canonical reflects the original final watch (2026-05-15); the rewatch row reflects its own most-recent episode (2026-05-16T20:00:00Z).
* **`rewatch_status: "active"`** because the session is mid-coverage (4/73). It auto-flips to `"completed"` when `watched_episodes_count >= total_episodes_count - not_aired_episodes_count`. See [Session states](#session-states) for the full rules.
* **`seasons[].episodes[]` only appears on the rewatch row** under this flag combo (`extended=full` + `episode_watched_at=yes`). Without `extended=full`, the rewatch row would arrive summary-only: `watched_episodes_count: 0` sentinel, no `seasons[]` block.
* **Movies are simpler** — no `seasons[]`, just `movie: { ... }`, and the rewatch row's `rewatch_status` always starts at `completed` (movies have nothing to track episode-wise).
* **Anime rewatch rows** have the same shape as show rows plus an `anime_type` field (`"tv"`, `"movie"`, etc.) on the rewatch row itself.
### Flag combinations for episode lists
| Flags (on top of `allow_rewatch=yes`) | What rewatch entries carry |
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| (none) | Summary only. `watched_episodes_count` returns `0` as a **sentinel** even when the session actually has episodes. |
| `extended=full` | Summary fields **populated** with real per-session counts, plus `seasons[].episodes[]` listing episode `number`. |
| `extended=full_anime_seasons` | Same plus anime `tvdb` mapping per episode. |
| `extended=full` + `episode_watched_at=yes` | Same plus per-episode `watched_at` timestamps. |
`episode_watched_at=yes` is a **modifier** — it adds timestamps to episodes already loaded. Without `extended=full`, episodes aren't loaded and the flag has no effect on rewatch entries.
## UI patterns (mirror simkl.com)
Filter `?allow_rewatch=yes` response for the current item and count by `rewatch_status`. `closed` is the "partial" count; `completed` is the clean-finish count.
```js theme={"theme":{"light":"github-light","dark":"vesper"}}
const sessions = response.filter(r => r.is_rewatch && r.show.ids.simkl === currentId);
const completed = sessions.filter(s => s.rewatch_status === 'completed').length;
const closed = sessions.filter(s => s.rewatch_status === 'closed').length;
const lastDate = sessions.map(s => s.last_watched_at).sort().pop();
```
Find the `active` session and show `watched_episodes_count / total_episodes_count`. Number the badge by chronological index among all sessions for the item — the number isn't stored on the server, you compute it client-side.
Movies don't really have an active phase — every movie rewatch session flips to `completed` immediately.
Fetch the active session with `?allow_rewatch=yes&extended=full&date_from=`, walk `total_episodes_count`, find the first episode not in the session's `seasons[].episodes[]` list, and POST it pinned to the session's `rewatch_id`.
All three are `POST /sync/history?allow_rewatch=yes`:
* **Reactivate** an older session — `rewatch_id: N, rewatch_status: "active"`. The previously-active session for the item auto-closes.
* **Start new** — write the first episode, or `is_rewatch: true` for an empty session on a Completed item.
* **Close** — `rewatch_id: N, rewatch_status: "closed"`. Keeps `watched_episodes_count`; resumable by another write to the same `rewatch_id`. No-op on a `completed` session — reactivate first.
**Pin `rewatch_id` on every write after the first.** Without it, finished sessions need `last_watched_at` to pinpoint which row to resume — otherwise the server creates a new row. (The singleton `active` session is always resumable by bare `is_rewatch: true`.)
Filter rewatch entries, sort by `last_watched_at` ascending. Label sessions "Rewatch #1", "Rewatch #2", etc. on the client.
## Limits and rules
* **Maximum 50 rewatches per item** (movie, show, or anime — combined cap).
* **One `active` session per item.** Making another session active auto-closes the previous active one.
* **2-day minimum gap** between watch events on the same movie or episode. See [The 2-day gap](#the-2-day-gap).
* **Simkl Pro / VIP only.** Free-tier writes are silent no-ops.
## Edge cases
Returns `200 OK` with `added: { movies: 0, shows: 0, episodes: 0 }`. No error code is surfaced. To prevent this in your app, check `account.type` from [`POST /users/settings`](/api-reference/simkl/get-user-settings) at sign-in (cache it locally; refetch only when `activities.settings.all` bumps — see the [Pro / VIP gate](#) at the top of this page). Reading the `added` counts in the response is a fallback if you can't gate up-front, but it's wasteful — the request still consumed a rate-limit slot.
Subsequent attempts past 50 may start being rejected once server-side enforcement is added. Count sessions client-side before exposing a "Start new rewatch" button.
Starting a new active session auto-closes the previous one (its `watched_episodes_count` is preserved). The old session is still readable and still counts toward the 50-cap.
A rewatch write — including one with `is_rewatch: true` — is rejected if the `watched_at` date matches an existing canonical episode/movie row. Use a different `watched_at` value, or omit the conflicting episode.
Second write collapses into the same session. `watched_episodes_count` doesn't increment; `last_watched_at` updates to the latest value. Intentional — see [The 2-day gap](#the-2-day-gap).
If an episode is already in the active session but a new write for that same episode arrives with a `watched_at` more than 48h away, the server closes the current active session (preserving its `watched_episodes_count`) and opens a fresh active session with the conflicting episode at the new timestamp. Common with offline-capable apps catching up after a long sync gap.
When a rewatch session is created through the simkl.com web UI's "Start new rewatch" button, the server bulk-inserts episode rows for every episode the user had previously watched (a snapshot of their prior progress). Sessions created via plain API writes don't do this. Re-fetch `/sync/all-items` after a session was created on simkl.com to see what's actually there.
Intentional *"watched, date unknown / long ago"* placeholder — not a NULL or bug. See [Dates and timezones → "Very long time ago" placeholder](/conventions/dates#very-long-time-ago).
## Reference
Two-phase model, `date_from` semantics, deletion reconciliation, edge cases.
Simple "mark watched" flow without rewatch sessions.
Endpoint reference + interactive playground.
Endpoint reference + interactive playground.
# Scrobbling playback
Source: https://api.simkl.org/guides/scrobble
Report real-time playback to Simkl with start, pause, stop, and checkin. Auto-mark items watched at 80% (stop) or 100% (checkin).
**Scrobbling** is how a media player reports real-time playback to Simkl. As the user starts, pauses, and finishes watching, your app sends events with the current `progress`. Simkl tracks the active session, shows the title in the user's "Watching now" banner, and marks the item watched once playback completes.
If you only need a "Mark as watched" button without real-time progress reporting, use [Mark as watched](/guides/mark-as-watched) instead.
## The lifecycle at a glance
Four endpoints. The 80% threshold is the only "magic number" you have to remember.
| Endpoint | Call when | Effect |
| ------------------------------------------------------ | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| **[`start`](/api-reference/simkl/scrobble-start)** | Playback begins or resumes | Creates an active session and shows the title in the user's "Watching now" banner. |
| **[`pause`](/api-reference/simkl/scrobble-pause)** | User pauses | Saves current `progress`. The session is resumable from any device on the same account. |
| **[`stop`](/api-reference/simkl/scrobble-stop)** | User stops or playback ends | `progress ≥ 80` → marked **watched**. `progress < 80` → kept as a **saved playback** (resumable later). |
| **[`checkin`](/api-reference/simkl/scrobble-checkin)** | Fire-and-forget alternative | One call, no progress tracking. Server auto-completes shortly after the item's runtime expiry — typically within \~2 minutes. |
**Send every identifier you have — `title`, `year`, and the full `ids` object.**
Simkl walks the `ids` object in priority order (`simkl` first when present, then external IDs like `imdb`, `tmdb`, `tvdb`, `mal`, `anidb`, …). If no ID resolves, it falls back to a `title` + `year` match, then to title-only as a last resort. Sending everything you know — title, year, plus every external ID your client has cached — maximizes the chance the right item gets credited. Extra fields are free; missing fields can cause a `404 id_err` or, worse, a silent mismatch.
**You don't need to search before writing.** Endpoints like `/scrobble/*`, `/sync/history`, `/sync/add-to-list`, and `/sync/ratings` resolve IDs server-side — pass whatever you have directly, no `/search/*` round-trip required. Calling `/search/id` first to "resolve" a Simkl ID is wasted work that doubles your API quota for no gain.
See [Supported ID keys](/conventions/standard-media-objects#supported-id-keys) for the full list.
## Which pattern do I need?
```mermaid theme={"theme":{"light":"github-light","dark":"vesper"}}
flowchart TD
Q1{Does your player emit play / pause / stop events?}
Q1 -->|yes| Q2{Need live 'Watching now' plus cross-device resume?}
Q1 -->|no, fire-and-forget| CHECKIN[Use POST /scrobble/checkin]
Q2 -->|yes| LOOP[Use start / pause / stop loop]
Q2 -->|no, just credit a watch| HISTORY[Use POST /sync/history]
LOOP --> RESULT1[Live tracking + cross-device resume progress >= 80 auto-marks watched]
CHECKIN --> RESULT2[Lightweight; auto-completes at runtime expiry no follow-up calls needed]
HISTORY --> RESULT3[Just records the watch no live status see Mark-as-watched guide]
```
**Do not poll `/scrobble/*` periodically.** Send events **only on real user actions** — pressed Play, Paused, Stopped — with the current `progress` percentage. Simkl automatically advances progress between events using the item's known runtime, so periodic re-posting wastes API quota and triggers rate limits.
## Universal player-event mapping
This is the table to keep open while you're wiring your player. It's intentionally player-agnostic — the same mapping works for HTML5 `