Skip to main content
Simkl is the central place where users store all their watch history and watchlists like Watching, Plan to Watch, On Hold, Dropped, Completed. The Sync API lets you read and write that data so every app and device stays in sync.

Where to go from here

Just want code?

Copy-paste two-phase sync in Node, Python, Swift (iOS), Kotlin (Android), or Dart (Flutter) — jumps to the Reference implementation at the bottom.

First time here?

Start with The two-phase model below. Read this before shipping anything that polls Simkl.

Looking up an endpoint?

Skip to the Sync API reference card grid at the bottom — every endpoint this guide touches, jump-linked to its playground.

Tracking rewatches?

Separate sub-feature with its own guide. If you need ?allow_rewatch=yes, session counts, or the simkl.com Rewatches panel, the Rewatches guide is required reading.
Never call watchlist endpoints without first checking /sync/activities. And never run unconditional background polling timers without active user interaction. Both shortcuts get apps throttled. The two-phase model below avoids both — see API rules — Sync incrementally for the rule itself.

The two-phase model

The whole loop uses just two endpoints:

GET /sync/activities

Activity timestamps — the “is anything new?” check that gates every poll.

GET /sync/all-items

The single delta endpoint. Both {type} and {status} segments are optional — narrow as needed.
Phase 1 happens once. Phase 2 happens forever. The transition is automatic — Phase 2 is just “Phase 1 with a 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:
extended=full and episode_watched_at=yes make the response significantly larger. On Phase 2 deltas, pair them with date_from so you only transfer the changed slice. The one place a full-library pull with these flags is expected is the one-time episode baseline on first sync — if your app tracks individual episodes, see Phase 1 → episode baseline.

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.
1

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.
2

Read /sync/activities first

Before the first pull, call /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.
3

Pull each type sequentially

Call /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.
Repeat with 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.
4

Save the snapshot

Only after every pull has succeeded, save the response you read in the second step as 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.
Read the snapshot before the pull, not after. A full pull of a large library takes a while, and the user can change something while it runs — on their phone, on simkl.com, in another app. If you read /sync/activities after the pull, that change is already counted in the timestamps you save, even though your pull may have read the item before it changed. Its timestamp is at or below your saved all, and date_from returns only items changed strictly after the value you send — so no later delta will ever return it.Reading first closes the gap. Anything that changes during the pull is newer than your snapshot, so the first Phase 2 delta picks it up. At worst you fetch one item twice, and merging it again is harmless.
Tracking episodes? Seed a watched-episode baseline on this first pull.The plain /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=full is mandatory — it’s what turns on the seasons[].episodes[] arrays. The other two flags are no-ops without it. (This is the trap: include_all_episodes on its own returns nothing.) It’s necessary but not sufficient on its own: extended=full alone loads episodes for watching / hold / plantowatch, but completed and dropped stay episode-less until you add include_all_episodes — which is most of a finished library.
  • include_all_episodes=yes is what you want for a baseline. It pulls in completed / dropped shows (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, so yes synthesizes 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 as 62/62 on the count but has zero episode rows.
  • Use include_all_episodes=original only if you specifically need real per-episode watched_at dates 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 than watched_episodes_count, so you’d lean on the count for how many and the rows for which ones and when.
This is a heavy payload (several MB to tens of MB on large libraries), so it’s a deliberate one-time call. Every Phase 2 delta afterward stays lean — you only add these flags to the 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:
In practice it is the episode rows that hit the limit: a very large library requested with 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 of watching, plantowatch, hold, completed and dropped instead of one call per type. Each response is a fraction of the whole.
  • Use include_all_episodes=original instead of =yes. yes adds a row for every episode of every completed show; original adds 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 its watched_episodes_count.
Don’t save the snapshot until every split request has succeeded — the same rule as any other Phase 1 pull.

Phase 2: Continuous sync

Every subsequent poll runs this loop:
1

Read /sync/activities

Response shape:
(Movies skip 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.
2

Compare it with your saved snapshot

If the top-level 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.
3

Fetch only what moved

Compare each bucket with the same bucket in your saved snapshot, and call only the endpoints for the buckets that moved. Every date_from below is the saved snapshot’s top-level all, passed exactly as returned.(Apps with an older 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} with shows, movies, or anime) — same delta semantics, scoped so you don’t transfer types you don’t render.
Send the date_from value exactly as /sync/activities returned it (ISO 8601 UTC). Don’t reformat it locally.
Need per-episode change detection? The bare 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:
With these flags, 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.
4

Merge, then save the new snapshot

Merge each returned item into your local store (don’t replace the whole watchlist — 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.
The rule behind both phases: a snapshot must be read before the fetch it anchors, and must not be saved if that fetch fails. 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.
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.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.
Why you have to diff, in plain terms. Simkl keeps no record of a removal. When an item leaves a list it is simply gone from that list — there is no “deleted” entry anywhere to fetch, and nothing that names which item it was.The one thing that is stored is a timestamp: 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:
  1. removed_from_list moves — your cue to run the check. Nothing else about that timestamp is useful.
  2. 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.
  3. 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.
Nothing else about your sync changes: the 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

Don’t call /sync/ratings on every sync. Ratings already come with the library: every item from /sync/all-items carries user_rating and user_rated_at. Your Phase 1 pull has every rating, and every item that comes back in a delta brings its current rating with it — no extra request needed.The ratings delta exists for one gap only, described below. Call it only when rated_at moved since your saved snapshot, and never if your app doesn’t show the user’s ratings.
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:
A multi-type app can drop the type and call /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: null means 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,10 keeps only rows whose rating is one of those values, so it silently drops the null rows — every removal.
Rating an item that is not in any list yet is the one exception: it also files the item into a list (see Automatic moves on rate above), and that new entry does come through /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 the watching 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.
Do not compute this from the episode counts. The arithmetic version —
— gets the common case right and the interesting case wrong. A user who watched the newest episode but skipped some older ones still has watched < aired, so this rule keeps the show on their list forever, no matter how many times they finish an episode.The error only runs one way. The count rule never hides a show it shouldn’t — it just fails to hide ones it should. That is the failure your users notice and report, and it is invisible in testing unless someone on your team happens to skip episodes.
Two entries from the same response, one caught up and one not:
The first entry is a user who watched the finale and skipped thirteen episodes along the way. They are done with the show; the counts cannot tell you that, and 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: Merge them into one row per title. If a show has a paused playback, that episode takes its row and you can show the 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.
A paused playback can be behind 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.
Both halves are gated by /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 in watching 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.
A status change is visible everywhere. It moves the show on simkl.com and in every other app the user has connected. Only do it when the user explicitly chooses it — never automatically, for example after a period of inactivity.
Two details:
  • Movies cannot go on hold. They skip watching and hold entirely (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_watch is still returned for hold entries, so a separate “pick back up” screen can show exactly where the user left off, and moving the show back to watching restores 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:
Read custom_lists.lists.all, not custom_lists.all — the extra level leaves room for other custom-list activity later. This is the gate the Custom Lists endpoints did not previously have. It is already in the snapshot you save every poll, so compare it with the saved copy and only refetch lists when it moves. That is the difference between polling a user’s lists on a timer and touching them a handful of times a day.

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 favorites alone. 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. recommendations moves 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.
This is why the snapshot keeps the whole /sync/activities response, not just all — you need last poll’s per-bucket values to know which ones moved.
auto does not tell you an auto list’s contents changed. Auto lists are saved Discover filters, and Simkl rebuilds their contents about once a day — that rebuild does not move this timestamp. The bucket moves only when the user edits the filter itself, so it is the quietest of the four, not the busiest.The practical consequence is the opposite of a noisy bucket: an auto list’s items can go stale without anything in /sync/activities moving. If you render auto lists, refresh them on your own schedule — daily is enough, since that is how often they are rebuilt. Use this bucket only to catch the user reconfiguring one.The other three buckets do not have this gap. regular, favorites and recommendations all move on the user action that changed them.
Watch the spelling seam. A list’s own type field says favorite (singular); the activity bucket is favorites (plural). They do not match, and a lookup keyed on one will silently miss the other.
The top-level 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.
All timestamps are UTC (ISO 8601 with a 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.
Simkl serialises Sync writes per user with a 20-second per-user lock. If you fire two writes back-to-back (e.g. retry a flaky 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:
What to do. Batch multiple items into one call instead of N parallel calls — every write endpoint accepts arrays. On a 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.
If a user reopens your app after weeks or months offline, you still call 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.
The same TMDB ID can resolve to a movie or an anime movie depending on Simkl’s catalog. The same TVDB ID can land in 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.
If a user removes an item and re-adds it later, the item shows up in the next 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.
The activities response is nested, not flat:
A single-type app that only renders TV shows can gate its poll on 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.
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.
Paused playback sessions are kept for:After the retention window, sessions are deleted server-side with no API notification. Your client may briefly show a “Continue Watching” entry that has since expired — refresh on app focus / pull-to-refresh to clear stale rows.See How playbacks work for the full lifecycle.
You can POST progress with float precision — 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:
If you need sub-percent precision client-side, compute it from current_position and runtime instead of trusting the round-tripped progress field.
If you call /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:
What to do. Treat 409 as a no-op success — the item is already on the user’s history, your work is done. Don’t retry. See Scrobble guide for the full lifecycle.
Calling 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=yes pull per type to seed your episode history (see Phase 1 → episode baseline), accepting the heavy one-time payload. Use =original instead of =yes only if you need real per-episode dates and can accept that bulk-completed shows return fewer rows.
Either way, don’t repeat the heavy pull — Phase 2 deltas keep it lean. On a very large library the server refuses the heavy pull outright with 400 max_items; see the fallback.

Common write operations

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).
Already-completed items don’t bump on subsequent POST /sync/history calls. To record an additional viewing as its own session, set ?allow_rewatch=yes on the request:
Do not enable ?allow_rewatch=yes until you’ve read the full Rewatches guide and implemented its precautions. Used carelessly — on retries, on every scrobble event, on importer re-runs, without pinning rewatch_id after the first write — the flag will pollute the user’s history stats and rewatches panel with phantom sessions. Gate it behind explicit user intent (a dedicated “Rewatch” button), never on background syncs or automated flows. Also expose a per-user Track rewatches toggle in your app’s settings — default off — so users who don’t want session complexity can stay on the simpler mark-watched-and-forget model.
Limits to plan for:
  • Plan gate. Only Simkl PRO / VIP accounts record rewatches. Free-tier callers get a silent no-op even with ?allow_rewatch=yes. Check account.type from POST /users/settings at sign-in, cache it, and refetch only when activities.settings.all bumps — that timestamp moves on plan upgrades / downgrades and any other profile update. Don’t fire ?allow_rewatch=yes from 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.
Why a 2-day gap? It looks arbitrary but it absorbs a long tail of false-positive rewatches that would otherwise pollute every user’s history. The short version: real rewatches happen on a real cadence, on the order of days, weeks, or months — not within hours. The 48-hour minimum encodes that reality and protects every client from a long list of common timestamping foot-guns:
  • 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_at field, 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/start and /scrobble/stop around 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/history several 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_at values. 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 progress can hit 80%+ multiple times during a single play (especially on seeks), each of which clients sometimes translate into a fresh POST /sync/history. The gap collapses these.
Without the gap, a single heavy user could accumulate dozens of phantom rewatch sessions per evening — breaking aggregate stats (“you’ve watched this 47 times this week”), inflating storage, and turning the rewatch history list on simkl.com into unreadable noise. The 48-hour rule isn’t there to be restrictive — it’s there to make rewatch tracking reliable for the user, regardless of how clients handle timestamps.

Rewatches guide — full walkthrough

Session lifecycle (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).
POST /sync/history/remove — same shape as /sync/history, but removes the items.
POST /sync/add-to-list with to set to one of watching, plantowatch, hold, dropped, completed (see Watchlist statuses — movies skip watching and hold):
POST /sync/ratings — pass a 1–10 rating per item.
Every write endpoint accepts arrays. Send 50 items in one call rather than 50 calls. The server-side cost is roughly the same; the network overhead drops 50×.

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

A minimal, complete reference for the snapshot-then-delta pattern above. Pick the tab that matches your stack, paste it in, and fill in your app’s storage (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.
The pattern, in plain English:
  1. Tell the code which types your app cares about — set SUPPORTED_TYPES at 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.
  2. initialSync() runs once, on first launch. Read /sync/activities first, pull each configured type, and only then save that activities response as the snapshot.
  3. sync() runs every poll after that. Read /sync/activities. If its top-level all matches 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.
The endpoint picks itself based on how many types you support — the same rule for /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.
React Native, Deno, Bun, Cloudflare Workers, and other fetch-based JS runtimes — the Node tab below works as-is. Persist state.snapshot and state.cache to your runtime’s storage (AsyncStorage for RN, localStorage / IndexedDB on web, KV for Workers).
Single-type and narrow-surface apps can poll an even cheaper signal than activities.all — see the drill-down accordion above (“/sync/activities is per-category — drill down for cheaper polls”) for the full pattern. Watch out for the one quirk: the activities response uses tv_shows while the endpoint paths use shows.

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 the ratings 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

Per-type, per-status timestamps — your incremental-sync gate.

GET /sync/all-items

Read library items. {type} and {status} are optional — multi-type, single-type, or single-bucket.

POST /sync/history

Mark items as watched — movies, episodes, or whole seasons.

POST /sync/history/remove

Undo a watched mark with the same payload shape.

POST /sync/add-to-list

Move items between Watching / Plan to Watch / Hold / Dropped / Completed.

POST /sync/ratings

Add or update a 1–10 user rating per item.

POST /sync/ratings/remove

Clear user-set ratings.

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), use GET /sync/playback (or narrow with /sync/playback/:type) — see How playbacks work for the full picture.