Where to go from here
Just want code?
First time here?
Looking up an endpoint?
Tracking rewatches?
?allow_rewatch=yes, session counts, or the simkl.com Rewatches panel, the Rewatches guide is required reading.The two-phase model
The whole loop uses just two endpoints:GET /sync/activities
GET /sync/all-items
{type} and {status} segments are optional — narrow as needed.date_from you didn’t have on the first run.”
Useful query params
The flags below shape what/sync/all-items returns and what /sync/history does — they’re optional, but most non-trivial integrations need at least one or two. Layer them onto the calls in Phase 1 / Phase 2 below as needed:
Phase 1: Initial sync
The first time a user signs in, you don’t have a saved timestamp, so you can’t ask for a delta — you have to pull the full library.Decide which types you need
- Multi-type apps (Plex/Jellyfin/Kodi plugins, full trackers) — pull all three: shows, movies, anime.
- Single-type apps (anime-only tracker, movie scrobbler, TV-show watchlist) — pull only the type you care about.
Pull each type sequentially
/sync/all-items/shows, then /sync/all-items/movies, then /sync/all-items/anime one after the other — not in parallel. Initial libraries can be massive, and back-to-back parallel pulls of three full payloads spike CPU on both ends.movies, then anime. Single-type apps call only their one endpoint.Save the bootstrap timestamp
/sync/activities once and save activities.all locally as state.lastSync. From now on every sync is Phase 2./sync/all-items/{type} call above returns item-level state — status, watched_episodes_count, last_watched, next_to_watch — but no per-episode rows. That’s everything an item-level app (movie tracker, show watchlist) needs, and it’s the smallest payload.If your app tracks individual episodes, the first sync is the one legitimate place to pull the full episode history in a single request. Add extended=full&episode_watched_at=yes&include_all_episodes=yes:extended=fullis mandatory — it’s what turns on theseasons[].episodes[]arrays. The other two flags are no-ops without it. (This is the trap:include_all_episodeson its own returns nothing.) It’s necessary but not sufficient on its own:extended=fullalone loads episodes forwatching/hold/plantowatch, butcompletedanddroppedstay episode-less until you addinclude_all_episodes— which is most of a finished library.include_all_episodes=yesis what you want for a baseline. It pulls incompleted/droppedshows (skipped by default) and — crucially — fills in every watched episode. For a show the user marked complete in one action there are no real per-episode rows stored, soyessynthesizes them (all stamped with the show’s last-watched time). That’s how you get a full watched-state baseline instead of a show that reads as62/62on the count but has zero episode rows.- Use
include_all_episodes=originalonly if you specifically need real per-episodewatched_atdates and can accept incomplete coverage — it returns just the episodes the user actually recorded, skipping the synthesized fill. A bulk-completed show can come back with fewer rows thanwatched_episodes_count, so you’d lean on the count for how many and the rows for which ones and when.
date_from call when you need to catch per-episode changes.Phase 2: Continuous sync
Every subsequent poll runs this loop:Check /sync/activities
watching and hold — see Watchlist statuses. The settings block bumps when the user changes their profile timezone, date / time format, or any other account-level preference — gate POST /users/settings re-fetches on settings.all, see Dates and timezones → User timezone preference.)Compare against your saved timestamp
activities.all === state.lastSync, stop here — nothing changed, no follow-up call needed. This is the cheap path that runs on the vast majority of polls.Fetch only the delta
activities.all moved, fetch the changed items with date_from set to your saved timestamp:- Multi-type apps:
/sync/all-items?date_from=YOUR_SAVED_TIMESTAMP— single request, all three types and every status, only items modified since. - Single-type apps:
/sync/all-items/{type}?date_from=YOUR_SAVED_TIMESTAMP(replace{type}withshows,movies, oranime) — same delta semantics, scoped so you don’t transfer types you don’t render.
date_from value exactly as /sync/activities returned it (ISO 8601 UTC). Don’t reformat it locally.date_from call returns summary fields only (status, counters, last_watched/next_to_watch markers) — no seasons[].episodes[] array. If your tracker app maintains an episode-level local cache, add extended=full&episode_watched_at=yes to the URL:watching/plantowatch/hold items get seasons[].episodes[].watched_at so you can apply per-episode diffs. For completed/dropped items also include include_all_episodes=yes (their episode arrays are gated separately to keep the default payload small). For rewatch sessions (?allow_rewatch=yes) the same rule applies — without extended=full, the rewatch entry comes back as a summary-only row with watched_episodes_count: 0 as a sentinel.Merge and update
date_from only returns deltas). Then save the new activities.all as state.lastSync for the next poll.Detecting deletions. date_from only surfaces items that were added or modified — not removed. When activities shows removed_from_list moved, refetch the full library with extended=simkl_ids_only (or ids_only if you also want external IDs) and diff against your local cache. Items missing from the new ID-only response have been removed from the user’s library — also clear any local rating you stored for them, since Simkl wipes the rating when an item is removed.Automatic moves on rate. When a user rates an item that isn’t in any list yet, Simkl auto-files it based on the item’s airing status: a released movie → Completed, an unreleased / upcoming movie → Plan to Watch, a single-episode show → Completed, any other show or anime → Watching. The auto-move bumps the corresponding list timestamp, so the rated item shows up in the next date_from delta even though the user only rated it. Treat the delta as authoritative — don’t try to second-guess why an item moved.Z suffix). Don’t reformat date_from locally — pass it back to Simkl exactly as /sync/activities returned it.watched_at near 1970-01-01T00:00:01Z is the “Very long time ago / I don’t remember” placeholder, not a corrupt date. Use it on writes when the user marks something watched without remembering when; render it on reads as “Very long time ago” (simkl.com’s own label) — never display the literal 1970-01-01. Full convention at Dates and timezones → “Very long time ago” placeholder.When to actually run sync
The sync loop above is cheap, but only when you trigger it on user-visible events. Don’t run unconditional background timers.Edge cases and gotchas
Real users do unusual things, clients have bugs, and networks drop. The behaviours below are what to expect when sync meets the messy real world — none of them break your integration, but each one can trip up a parser, a UI assumption, or a retry loop.One sync write per user at a time — 20-second lock
One sync write per user at a time — 20-second lock
POST /sync/history while the original is still being processed), the second request blocks until the first finishes or the 20-second timeout fires. On timeout, the second call returns 400 rate_limit:rate_limit 400, wait a few seconds and retry once. The lock covers /sync/history, /sync/history/remove, /sync/add-to-list, /sync/ratings, /sync/ratings/remove, and /sync/watched. Read endpoints (/sync/activities, /sync/all-items) are not gated by this lock.Long offline gaps are safe — `date_from` accepts any past timestamp
Long offline gaps are safe — `date_from` accepts any past timestamp
GET /sync/all-items?date_from=<your old saved timestamp> and the server returns the cumulative delta of everything that changed since. There is no maximum age on date_from — older timestamps simply return a larger response.You never need to fall back to a full Phase 1 sync unless your local cache is gone or corrupted. The only thing that can actually go stale across long gaps is the user’s access token — see Tokens for how revocation works.Items can be reclassified across types — read `simkl_type` and `anime_type`
Items can be reclassified across types — read `simkl_type` and `anime_type`
shows or anime. When you POST an item with an external ID and the response says it landed in a category you didn’t expect, that’s not a bug — it’s Simkl correcting your classification.Every POST /sync/history response includes a simkl_type (movie / tv / anime) and anime_type (tv / special / ova / movie / music video / ona) on each added.statuses[].response entry. Store these locally so a later deletion of “the anime Akira” targets the right type, even if you originally POSTed it as a TMDB movie.Same when reading /sync/all-items: the top-level key the item lands under (shows vs movies vs anime) tells you Simkl’s classification — your local store needs to follow it.The same added.statuses[].response object also carries status — the resolved Watchlist status the server placed the item on. A "completed" write on a still-airing show silently becomes "watching"; read that field and reflect it locally. No follow-up POST /sync/add-to-list is needed — /sync/history already moved the item.Re-added items reappear in deltas, with watch-state preserved
Re-added items reappear in deltas, with watch-state preserved
date_from delta as a fresh write. The corresponding watchlist timestamp on /sync/activities (e.g. tv_shows.plantowatch) bumps; removed_from_list already bumped at the deletion. Your client should treat a re-appearing simkl_id as a current entry — overwrite any local “removed” flag.History (watched episodes, watched_at timestamps) survives the remove/re-add cycle. Ratings do not — Simkl wipes the user-set rating when an item is removed from the list, so if a re-added item comes back unrated, that’s expected.`/sync/activities` is per-category — drill down for cheaper polls
`/sync/activities` is per-category — drill down for cheaper polls
tv_shows.all instead of the top-level all — and skip the call entirely when movies or anime moved but shows didn’t. Same trick for narrower surfaces: a “Continue Watching” rail only cares about tv_shows.playback / movies.playback / anime.playback; a ratings screen only cares about *.rated_at. Saving and comparing the narrower timestamp halves your API calls for apps with a focused UI.Playback sessions auto-hide once the item is marked watched
Playback sessions auto-hide once the item is marked watched
GET /sync/playback returns only open sessions — items the user has paused and hasn’t finished. As soon as a watch event lands for that item after the pause time, Simkl filters the session out of GET responses.No action needed on your side — this is exactly what you want for a “Continue Watching” rail: once the user finishes the episode, it disappears from the resume list automatically.Playback retention varies by subscription tier
Playback retention varies by subscription tier
Playback `progress` rounds to integer on read
Playback `progress` rounds to integer on read
progress: 75.5 is valid on /scrobble/start, /scrobble/pause, /scrobble/stop, and /scrobble/checkin. The server stores it accurately, but reads round to the nearest integer:current_position and runtime instead of trusting the round-tripped progress field.`409 Conflict` on `/scrobble/stop` for recently-watched items
`409 Conflict` on `/scrobble/stop` for recently-watched items
/scrobble/stop on an item that was already marked watched within the last hour, the server rejects the call with 409 Conflict to prevent duplicate scrobbles. The response body includes the original watched_at and an expires_at showing when the 1-hour duplicate-window closes:`extended=full` without `date_from` will hurt — quantifiably
`extended=full` without `date_from` will hurt — quantifiably
GET /sync/all-items?extended=full (no date_from) returns the user’s entire library with overview text, genres, ratings, posters, runtime, and per-season episode arrays for every item — often several megabytes per call. Combine with episode_watched_at=yes on a heavy completed watchlist and the payload can hit tens of megabytes.On Phase 2 deltas, pair extended=full and episode_watched_at=yes with date_from so the response is just the changed slice. First sync has two legitimate shapes depending on what your app tracks:- Item-level apps — pull the plain
/sync/all-items/{type}(no flags) for a fast, minimal payload, then enrich catalog metadata per-item with the dedicated/movies/{id},/tv/{id},/anime/{id}calls as the user opens them. Note those detail endpoints return catalog data, not the user’s watch state — they can’t tell you which episodes were watched. - Episode-level apps — do one
extended=full&episode_watched_at=yes&include_all_episodes=yespull per type to seed your episode history (see Phase 1 → episode baseline), accepting the heavy one-time payload. Use=originalinstead of=yesonly if you need real per-episode dates and can accept that bulk-completed shows return fewer rows.
Common write operations
Mark items watched
Mark items watched
POST /sync/history accepts movies, shows, anime, and episodes arrays. For shows / anime you can specify which seasons or episodes to mark. Anime entries are equally valid under shows[] or anime[] — Simkl resolves the catalog by ids either way (see Anime in shows[] or anime[] for details and the not_found.shows caveat).Record a rewatch — Simkl Pro / VIP only
Record a rewatch — Simkl Pro / VIP only
POST /sync/history calls. To record an additional viewing as its own session, set ?allow_rewatch=yes on the request:- Plan gate. Only Simkl Pro / VIP accounts record rewatches. Free-tier callers get a silent no-op even with
?allow_rewatch=yes. Checkaccount.typefromPOST /users/settingsat sign-in, cache it, and refetch only whenactivities.settings.allbumps — that timestamp moves on plan upgrades / downgrades and any other profile update. Don’t fire?allow_rewatch=yesfrom a free-tier client; the request consumes a rate-limit slot regardless. - Up to 50 rewatches per item (movie, show, or anime).
- 2-day minimum gap between watch events on the same item. Movies and individual episodes — a new rewatch closer than 48 hours to the previous watch of the same item collapses into the same session. It’s a rewatch, not a rewind 😄. Re-watching different episodes back-to-back is fine.
- Sleep-and-resume. User starts an episode at midnight, falls asleep, finishes it the next morning. That’s one viewing, not two — but the two timestamps the client reports can be 6–10 hours apart and trivially look like distinct watches.
- Wrong timezones in clients. Apps frequently label local time as UTC (or vice versa) on the
watched_atfield, producing a 1–12-hour drift on every write. The 2-day buffer absorbs the worst-case drift without spawning fake rewatch sessions. - DST transitions. Naive datetime libraries miscalculate by an hour twice a year, in the days surrounding the spring-forward / fall-back boundary. Same buffer covers them.
- Clock drift on offline-capable apps. Mobile, set-top, and console clients that batch writes after coming back online often back-date events using the current device clock minus a rough offset, not the actual playback time. Multi-hour drift is common.
- Scrobble pause/resume noise. Some media players re-fire
/scrobble/startand/scrobble/stoparound a pause (bathroom break, doorbell, phone call). Without a gap, the resume reads like a second viewing. - Multi-device duplicates. A user’s phone, TV, and home-theatre receiver can all see the same file open and each report a play event. Same item, near-identical timestamps — the gap collapses them into one session.
- Network retry storms. A flaky connection causes a client to retry
POST /sync/historyseveral times for one watch event. The gap collapses the retries instead of pretending each retry was a separate rewatch. - Importer re-runs. Users importing history from another source often re-run the import to catch missed items, re-submitting the same
watched_atvalues. Without the gap, every re-run inflates the rewatch count. - Background play. A TV left on for noise can auto-loop the same episode, or a chromecast can re-cast the same title on idle. The user isn’t really rewatching it.
- Buggy progress reporting. A media player that miscalculates
progresscan hit 80%+ multiple times during a single play (especially on seeks), each of which clients sometimes translate into a freshPOST /sync/history. The gap collapses these.
Rewatches guide — full walkthrough
active / completed / closed), per-item rewatch fields (rewatch_id, rewatch_status, last_watched_at, is_rewatch), reading sessions back from GET /sync/all-items, episode-level tracking, and ready-made code for the UI patterns simkl.com uses on every movie / show / anime detail page (rewatch indicator, “mark next episode rewatched”, resume, close, history list, stats).Remove items from history
Remove items from history
POST /sync/history/remove — same shape as /sync/history, but removes the items.Move an item to a Watchlist status
Move an item to a Watchlist status
POST /sync/add-to-list with to set to one of watching, plantowatch, hold, dropped, completed (see Watchlist statuses — movies skip watching and hold):Add ratings
Add ratings
POST /sync/ratings — pass a 1–10 rating per item.Always batch writes
Always batch writes
Supported ID keys
When you POST items to/sync/history, /sync/add-to-list, or /sync/ratings, the ids object can match on any of simkl, imdb, tmdb, tvdb, mal, anidb, anilist, kitsu, livechart, anisearch, animeplanet, netflix, letterboxd, traktslug, crunchyroll, hulu. Sending more than one is fine — Simkl walks the IDs in order and falls back to title/year matching. See the full table with types and examples in Standard media objects → Supported ID keys.
Reference implementation
Two-phase sync in 5 languages — Node, Python, Swift, Kotlin, Dart
state.cache / state.lastSync) and the mergeItems(old, new) helper. Error handling, retries, and rate-limit backoff are deliberately out of scope to keep the sync flow readable — wrap calls in your app’s normal HTTP-error handling before shipping.- Tell the code which types your app cares about — set
SUPPORTED_TYPESat the top. Use['shows', 'movies', 'anime']for everything,['shows', 'movies']for TMDB-only apps that don’t surface anime,['anime']for an anime-only client. initialSync()runs once, on first launch. No saved watermark yet → pull each configured type, then save the currentactivities.allas the watermark.sync()runs every poll after that. Cheap call to/sync/activities. Ifactivities.allhasn’t moved since your watermark, exit. If it has, fetch only the delta, merge it, save the new watermark.
- One type →
/sync/all-items/{type}?date_from=...— smaller response, only your one bucket. - Two or three types → bare
/sync/all-items?date_from=...— one HTTP call covers every type, cheaper than per-type calls on round-trips and rate-limit hits.
state is whatever your app uses to remember things between runs (local DB, AsyncStorage, file on disk, IndexedDB, KV store, …). The samples treat it as an opaque object with two properties — how you persist them is up to your app:
mergeItems(oldList, newList) is a helper your app implements: combine two arrays of items, deduping by ids.simkl, with the newer copy winning on conflict.
state.lastSync and state.cache to your runtime’s storage (AsyncStorage for RN, localStorage / IndexedDB on web, KV for Workers).Ratings
The Sync API handles user-set ratings (the 1-10 scores the user has personally assigned). For Simkl’s average ratings (the community score), the data is included on every detail-endpoint response under theratings field — call GET /movies/{id}, GET /tv/{id}, or GET /anime/{id}. Detail endpoints don’t need a token and are Cloudflare-cached. If you only have an external ID, resolve it first via GET /redirect. User-set ratings (the Sync side) also support date_from for incremental sync via GET /sync/ratings?date_from=….
Sync API reference
Every endpoint this guide touches, jump-linked:GET /sync/activities
GET /sync/all-items
{type} and {status} are optional — multi-type, single-type, or single-bucket.POST /sync/history
POST /sync/history/remove
POST /sync/add-to-list
POST /sync/ratings
POST /sync/ratings/remove
What about real-time playback?
Sync handles watchlist state and history. To report playback as it happens (start / pause / stop with progress), use the Scrobble guide. To read saved pause points (e.g. for a “Continue Watching” rail), useGET /sync/playback (or narrow with /sync/playback/:type) — see How playbacks work for the full picture.