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.
Read /sync/activities first
/sync/activities and keep the whole response in memory. Don’t save it yet.This is your first snapshot: the user’s activity timestamps as they stood just before you started reading their library.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.There is no separate ratings pull. Every item already carries the user’s own score in user_rating (with user_rated_at), so this pull gives you every rating as well. Don’t call /sync/ratings here.Save the snapshot
state.snapshot. From now on every sync is Phase 2.If any pull failed, save nothing and run Phase 1 again later. A snapshot saved over an incomplete library tells every later delta to skip what you missed./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. On a very large library the server can refuse it outright — see the next section.If a pull returns 400 max_items
GET /sync/all-items builds its whole response before sending it, so it refuses a request whose response would be too large to build:
extended=full, especially with include_all_episodes=yes. The message says which limit was reached. Retrying the same request gets the same 400, so change the request instead:
- Split it — one status per call. Use
/sync/all-items/{type}/{status}for each ofwatching,plantowatch,hold,completedanddroppedinstead of one call per type. Each response is a fraction of the whole. - Use
include_all_episodes=originalinstead of=yes.yesadds a row for every episode of every completed show;originaladds only the episodes the user actually watched — far fewer on a library of shows marked complete in one action. The trade-off is the one in the table above: real dates, but a bulk-completed show can return fewer rows than itswatched_episodes_count.
Phase 2: Continuous sync
Every subsequent poll runs this loop:Read /sync/activities
watching and hold — see Watchlist statuses. custom_lists.lists is covered below — note it is nested one level deeper than the other blocks. 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.)This response is your new snapshot. Keep it in memory: you save it at the end of this loop, and only if every fetch in between succeeds.Compare it with your saved snapshot
all equals the one in your saved snapshot, stop here — nothing changed anywhere, no follow-up call needed. This is the cheap path that runs on the vast majority of polls.The top-level all is the newest timestamp in the whole response — every type block, settings, custom_lists, ratings and playback included — and each block’s own all is the newest inside that block. So an unchanged all means nothing moved, and a changed one only tells you that something did. The individual buckets tell you what.Fetch only what moved
date_from below is the saved snapshot’s top-level all, passed exactly as returned.client_id see notinteresting where this table says dropped — see Watchlist statuses.)Only call the endpoints for data your app actually shows. A movie scrobbler that never displays ratings ignores rated_at; an app without a “continue watching” row ignores playback. A bucket you don’t render is one you never need to fetch.For the watchlist delta:- Multi-type apps:
/sync/all-items?date_from=…— single request, all three types and every status, only items modified since. - Single-type apps:
/sync/all-items/{type}?date_from=…(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, then save the new snapshot
date_from only returns deltas). Then save the snapshot from the first step as state.snapshot — the whole response, not just all, because the next poll compares it bucket by bucket.If any fetch failed, don’t save it. Keep the old snapshot. The next poll sends the same date_from and gets everything you missed; merging an item you already have is harmless, so a retry costs a few duplicate rows and nothing else.date_from returns only what changed strictly after the value you send, so a snapshot newer than what you actually fetched skips changes for good, while an older one only re-fetches a little.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.A new episode airing also moves activities — with no user action at all. When an episode airs, Simkl fills in next_to_watch for the users who were caught up on that show: status watching, and the previous episode already watched. Those users get tv_shows.watching (or anime.watching) bumped, and therefore all too.This is the one case where /sync/activities changes while the user is asleep, and it is a feature: your ordinary activities-then-date_from poll already returns the show with next_to_watch filled in, so it reappears in your “up next” list on its own, with no extra request.Detecting deletions
date_from only surfaces items that were added or modified — not removed. When removed_from_list moved in any type you sync, fetch 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.
removed_from_list in /sync/activities. It tells you that something was removed at some point, never what.That is why no delta can hand you deletions. date_from returns items that exist and have changed — and a removed item does not exist any more, so there is nothing for it to return.So the only way to find out is to ask what the user has now and compare it with what you think they have:removed_from_listmoves — your cue to run the check. Nothing else about that timestamp is useful.- Fetch every ID they currently hold with
extended=simkl_ids_only. This is deliberately a small response — IDs and nothing else — so it is cheap to pull in full. - Whatever is in your cache but not in that response is gone. Those are your deletions. Remove them locally, and drop any rating you stored for them.
date_from deltas still handle every addition and edit. The full ID fetch exists only to answer “what disappeared?”, and only when that timestamp moves.Ratings come in their own delta
That gap: changing or removing a rating never puts the item into a/sync/all-items delta. A rating moves rated_at in /sync/activities, not the item’s watchlist timestamp — so an app that shows user_rating and syncs only through /sync/all-items keeps showing the old score, or a score the user has removed.
When rated_at moved, fetch the ratings delta with the same date_from:
/sync/ratings?date_from=… once for all three, the same way it calls /sync/all-items.
- Each row is the item’s full watchlist entry, in the same shape as
/sync/all-items, so the merge you already have works unchanged. - A row with
user_rating: nullmeans the item has no rating now — that is how a removed rating comes through. Clear the score you stored. - Leave the rating segment off.
/sync/ratings/movies/1,2,3,4,5,6,7,8,9,10keeps only rows whose rating is one of those values, so it silently drops thenullrows — every removal.
/sync/all-items. Changing or removing the rating of an item already in a list does not.Hide shows the user is caught up on
A “Watching” screen that keeps listing shows the user has finished is the most common complaint about tracker apps, and the fix is one field. In thewatching status, hide a show or anime when next_to_watch is null.
next_to_watch is the first unwatched regular episode after the last one the user watched — specials never count. It is null when no aired episode is left after that point, which is exactly the definition of caught up.
Nothing extra is needed to get it: it is in the default /sync/all-items response and in date_from deltas. next_watch_info=yes is a separate, optional field that adds the episode’s title and air date; you do not need it just to filter. The only responses without next_to_watch are extended=ids_only and extended=simkl_ids_only, which strip everything but IDs.
Two entries from the same response, one caught up and one not:
next_to_watch can.
next_to_watch is returned for every status, not only watching. A dropped or hold entry normally still carries one, because the field answers “is there anything left to watch?” rather than “what status is this in?”. That is useful for a “pick this back up” screen — but it also means you must check status yourself when you only want the watching list. It is omitted entirely on movies.Build a “continue watching” screen
There is no single endpoint for this. It is two lists you already sync, merged:progress bar; otherwise the row shows next_to_watch. Only one paused playback is kept per title, so there is never more than one to choose from.
Sort by recency: the playback’s watched_at (when it was paused) against the watchlist entry’s last_watched_at, newest first.
next_to_watch. Playbacks also cover rewatches, so the paused episode may be one the user already finished. Showing the paused one is usually right — it is what they were actually watching last./sync/activities like everything else: tv_shows.playback / anime.playback / movies.playback move when a playback changes, and watching moves when the list does — including when a new episode airs.
Removing a title from the screen
Deleting the playback alone does not do it: the show is still inwatching with a next_to_watch, so it comes straight back on the next sync. Offer the user one of two things.
Hide it in your app. Keep a local list of titles the user dismissed and filter them out. Nothing changes on Simkl or in any other app, which makes this the right default for a quick “not interested right now” swipe.
Put it on hold on Simkl. Move the show to hold with /sync/add-to-list, and delete its playback if it has one. hold means “paused, may come back”, which matches what the user is asking for. dropped means “gave up” — offer that as a separate, explicit choice, not as the result of a remove button.
- Movies cannot go on hold. They skip
watchingandholdentirely (see list statuses), so a movie is on this screen only because of a paused playback. Removing one means deleting that playback. - Held shows keep their place.
next_to_watchis still returned forholdentries, so a separate “pick back up” screen can show exactly where the user left off, and moving the show back towatchingrestores it here.
Episode counts can go stale
total_episodes_count and not_aired_episodes_count are refreshed on the server as episodes air or get added. That refresh does not bump /sync/activities, and it does not put the item into a date_from delta.
So an app that only ever fetches deltas can sit on counts that are months out of date for a show the user has not touched. Nothing is broken — the item genuinely has not changed from the user’s point of view — but any decision you make from that arithmetic is made from stale numbers.
This is the second reason to use next_to_watch for “is there anything left to watch?”: it is maintained on the paths that do reach your delta, including when a new episode airs. If you need accurate counts for their own sake — a progress bar, an “N episodes left” label — refresh them on your own schedule with a full (non-date_from) fetch rather than assuming your cache is current.
Custom Lists have their own timestamps
/sync/activities carries a sixth block for Custom Lists, and it is nested one level deeper than the others:
custom_lists.lists.all, not custom_lists.all — the extra level leaves room for other custom-list activity later.
Use all as the gate, the four buckets as the router
all answers “is there anything to do?”. The other four answer “what exactly?” — and that second question is what keeps a refresh proportionate to the change. A user favouriting one show should not cost you a full re-read of every list they own.
Two cases where the split does real work:
- You show favourites in your UI — a heart on a poster, a badge on the detail screen, a “Favourites” row. Watch
favoritesalone. When a user favourites something on the Simkl website, that bucket moves and nothing else does, so you refetch one list and leave the rest of your cache untouched. - The user refreshed their recommendations.
recommendationsmoves on its own. Refetch just those lists — their hand-made lists have not changed, and re-reading them would be wasted calls against the user’s allowance.
/sync/activities response, not just all — you need last poll’s per-bucket values to know which ones moved.
all moves for custom-list changes too. A list edit moves custom_lists.lists.* and nothing in the type blocks, so the Phase 2 router calls no watchlist endpoint for it. A loop that gates only on all and then always fetches the watchlist delta would wake for every list edit and find nothing — which looks like a spurious wake-up, not a bug.This block is returned to AUTH V1 apps as well, even though the Custom Lists endpoints themselves require V2. A V1 app can therefore see that lists changed without being able to read them.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 saved snapshot's all> 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 and refresh for expiry, refreshing and revocation.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.
400 max_items; see the fallback.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.snapshot) and the mergeItems(old, new) helper. Retries and rate-limit backoff are deliberately out of scope to keep the sync flow readable. The one piece of error handling that is in: the GET helper throws on any non-2xx response, so a failed fetch can never save a snapshot.- 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. Read/sync/activitiesfirst, pull each configured type, and only then save that activities response as the snapshot.sync()runs every poll after that. Read/sync/activities. If its top-levelallmatches the saved snapshot’s, exit. Otherwise route on the buckets that moved — watchlist statuses → the item delta,rated_at→ the ratings delta,removed_from_list→ the ID diff — and save the new snapshot last.
/sync/all-items and /sync/ratings:
- 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. Ratings-delta rows have the same shape as /sync/all-items rows, so the same helper merges both.
On a brand-new account all can be null until the user does something. The samples handle that by leaving date_from off the next fetch, which returns everything — on an account that new, that is next to nothing.
state.snapshot 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) arrive with the library — every /sync/all-items item carries user_rating — so sync needs no separate ratings request. The one exception is a rating changed or removed on an item whose list entry did not change: fetch those with GET /sync/ratings?date_from=…, only when rated_at moved, and with no rating segment so removed ratings come through as null. See Ratings come in their own delta.
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.