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

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

Save the bootstrap timestamp

After Phase 1 completes, fetch /sync/activities once and save activities.all locally as state.lastSync. From now on every sync is Phase 2.
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.

Phase 2: Continuous sync

Every subsequent poll runs this loop:
1

Check /sync/activities

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

Compare against your saved timestamp

If 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.
3

Fetch only the delta

If 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} 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 and update

Merge each returned item into your local store (don’t replace the whole watchlist — 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.
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 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.
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.

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 initial-pull-then-delta-loop pattern below. Pick the tab that matches your stack, paste it in, and fill in your app’s storage (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.
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. No saved watermark yet → pull each configured type, then save the current activities.all as the watermark.
  3. sync() runs every poll after that. Cheap call to /sync/activities. If activities.all hasn’t moved since your watermark, exit. If it has, fetch only the delta, merge it, save the new watermark.
The delta endpoint picks itself based on how many types you support:
  • 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.
React Native, Deno, Bun, Cloudflare Workers, and other fetch-based JS runtimes — the Node tab below works as-is. Persist state.lastSync 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) 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

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.