> ## Documentation Index
> Fetch the complete documentation index at: https://api.simkl.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Sync — watchlists, history, and ratings

> Two-phase sync model: a one-time initial pull, then incremental deltas via /sync/activities + date_from. Keeps your app fast and Simkl's servers happy.

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

<CardGroup cols={2}>
  <Card title="Just want code?" icon="code" href="#reference-implementation">
    Copy-paste two-phase sync in **Node, Python, Swift (iOS), Kotlin (Android), or Dart (Flutter)** — jumps to the Reference implementation at the bottom.
  </Card>

  <Card title="First time here?" icon="book-open" href="#the-two-phase-model">
    Start with **The two-phase model** below. Read this before shipping anything that polls Simkl.
  </Card>

  <Card title="Looking up an endpoint?" icon="list" href="#sync-api-reference">
    Skip to the **Sync API reference** card grid at the bottom — every endpoint this guide touches, jump-linked to its playground.
  </Card>

  <Card title="Tracking rewatches?" icon="rotate" href="/guides/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](/guides/rewatches) is required reading.
  </Card>
</CardGroup>

<Warning>
  **Never call watchlist endpoints without first checking [`/sync/activities`](/api-reference/simkl/get-activities). And never run unconditional background polling timers without active user interaction.** Both shortcuts get apps throttled. The two-phase model below avoids both — see [API rules — Sync incrementally](/api-rules#7-be-a-good-api-citizen) for the rule itself.
</Warning>

## The two-phase model

The whole loop uses just two endpoints:

<CardGroup cols={2}>
  <Card title="GET /sync/activities" icon="clock-rotate-left" href="/api-reference/simkl/get-activities">
    Activity timestamps — the "is anything new?" check that gates every poll.
  </Card>

  <Card title="GET /sync/all-items" icon="arrows-rotate" href="/api-reference/simkl/get-all-items">
    The single delta endpoint. Both `{type}` and `{status}` segments are optional — narrow as needed.
  </Card>
</CardGroup>

| Phase                         | When                         | Endpoint                                                                                                  | `date_from`?                        |
| ----------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------- |
| **Phase 1 — Initial sync**    | First time the user signs in | `/sync/all-items/shows`, `/sync/all-items/movies`, `/sync/all-items/anime`                                | **No** — pull the full library      |
| **Phase 2 — Continuous sync** | Every poll afterwards        | `/sync/all-items?date_from=…` (multi-type apps) or `/sync/all-items/:type?date_from=…` (single-type apps) | **Yes** — pass your saved timestamp |

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:

| Param                                         | On                                          | What it does                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| --------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `extended=simkl_ids_only`                     | `GET /sync/all-items`                       | Returns just `ids.simkl` per item — perfect for the deletion-reconciliation diff in Phase 2.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `extended=ids_only`                           | `GET /sync/all-items`                       | Same, plus external IDs (`imdb`, `tmdb`, `tvdb`, `mal`, …).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `extended=full`                               | `GET /sync/all-items`                       | Adds posters, overview, fanart, ratings — full metadata per item. **Always pair with `date_from`.**                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `extended=full_anime_seasons`                 | `GET /sync/all-items` (anime)               | At the **show** level, adds `mapped_tvdb_seasons: [n,…]` mapping each Simkl/AniDB season to a TVDB season. At the **episode** level, adds a `tvdb: \{ season, episode \}` block on every anime episode. Anime apps that index against TVDB don't need a second lookup.                                                                                                                                                                                                                                                                                                                                                                                              |
| `episode_watched_at=yes`                      | `GET /sync/all-items`                       | Adds per-episode `watched_at` timestamps to every episode in the response. Modifier only — episodes must already be loaded (see `include_all_episodes` and `extended=full`). **Always pair with `date_from`** — significantly larger response.                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `include_all_episodes=yes` *(or `=original`)* | `GET /sync/all-items`                       | Loads canonical `seasons[].episodes[]` for items in **every** watchlist status — including `completed` and `dropped`, which skip episode loading by default. `yes` also synthesizes virtual episode rows (with air-date timestamps) for completed entries that have no per-episode data. `original` loads real per-episode rows only — no virtual synthesis.                                                                                                                                                                                                                                                                                                        |
| `next_watch_info=yes`                         | `GET /sync/all-items`                       | For items with status `watching` that have a "next to see" episode, attaches a `next_to_watch_info` object (`title`, `season`, `episode`, `date`). Anime entries omit `season`. Items without a next episode (or with other statuses) don't get the field.                                                                                                                                                                                                                                                                                                                                                                                                          |
| `episode_tvdb_id=yes`                         | `GET /sync/all-items`                       | Adds `ids.tvdb_id` on each episode in episode-bearing responses.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `memos=yes`                                   | `GET /sync/all-items`                       | Includes the user's per-item `memo` object (`text` capped at 140 chars, plus `is_private`). Note: field name is singular `memo` (not `memos`); empty memos render as `\{\}`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `anime_type=…`                                | `GET /sync/all-items`                       | Filter anime entries by anime type (`tv`, `movie`, `ova`, `ona`, `special`, `music video`).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `language=en`                                 | `GET /sync/all-items`                       | Force English titles instead of the user's profile language.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `skip_auto_watching=yes`                      | `POST /sync/history`                        | Suppresses the implicit episode auto-fill that happens when you POST a show without `seasons`/`episodes`. Use when your client manages episode-level state itself.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `allow_rewatch=yes`                           | `POST /sync/history`, `GET /sync/all-items` | Opt into rewatch tracking. On `POST /sync/history`, recording a watch on an already-Completed item creates a separate rewatch session instead of being a no-op. On `GET /sync/all-items`, any item with saved rewatch sessions appears multiple times — its normal entry, plus one extra entry per rewatch session. **Simkl Pro / VIP only** — non-Pro callers see no effect even with the flag set. Gate the flag on `account.type` from [`POST /users/settings`](/api-reference/simkl/get-user-settings); cache the value and refetch only when `activities.settings.all` bumps. See [Rewatches guide → Pro / VIP gate](/guides/rewatches#) for the full pattern. |

<Tip>
  **`extended=full` and `episode_watched_at=yes` make the response significantly larger.** Always pair them with `date_from` so you're only transferring the changed slice — never on a full library pull during Phase 1.
</Tip>

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

<Steps>
  <Step title="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.
  </Step>

  <Step title="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.

    ```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
    curl "https://api.simkl.com/sync/all-items/shows?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
      -H "User-Agent: my-app-name/1.0" \
      -H "Authorization: Bearer ACCESS_TOKEN"
    ```

    Repeat with `movies`, then `anime`. Single-type apps call only their one endpoint.
  </Step>

  <Step title="Save the bootstrap timestamp">
    After Phase 1 completes, fetch [`/sync/activities`](/api-reference/simkl/get-activities) once and save `activities.all` locally as `state.lastSync`. From now on every sync is Phase 2.
  </Step>
</Steps>

## Phase 2: Continuous sync

Every subsequent poll runs this loop:

<Steps>
  <Step title="Check /sync/activities">
    ```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
    curl "https://api.simkl.com/sync/activities?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
      -H "User-Agent: my-app-name/1.0" \
      -H "Authorization: Bearer ACCESS_TOKEN"
    ```

    Response shape:

    ```json theme={"theme":{"light":"github-light","dark":"vesper"}}
    {
      "all": "2026-05-08T14:23:11Z",
      "settings":  { "all": "..." },
      "tv_shows":  { "all": "...", "watching": "...", "plantowatch": "...", "hold": "...", "completed": "...", "dropped": "...", "rated_at": "...", "playback": "...", "removed_from_list": "..." },
      "anime":     { "all": "...", "watching": "...", "plantowatch": "...", "hold": "...", "completed": "...", "dropped": "...", "rated_at": "...", "playback": "...", "removed_from_list": "..." },
      "movies":    { "all": "...", "plantowatch": "...", "completed": "...", "dropped": "...", "rated_at": "...", "playback": "...", "removed_from_list": "..." }
    }
    ```

    (Movies skip `watching` and `hold` — see [Watchlist statuses](/conventions/list-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](/conventions/dates#user-timezone-preference).)
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.

    ```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
    curl "https://api.simkl.com/sync/all-items?date_from=2026-05-08T14:23:11Z&client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
      -H "User-Agent: my-app-name/1.0" \
      -H "Authorization: Bearer ACCESS_TOKEN"
    ```

    Send the `date_from` value **exactly as `/sync/activities` returned it** (ISO 8601 UTC). Don't reformat it locally.

    <Note>
      **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:

      ```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
      curl "https://api.simkl.com/sync/all-items?date_from=2026-05-08T14:23:11Z&extended=full&episode_watched_at=yes&client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
        -H "User-Agent: my-app-name/1.0" \
        -H "Authorization: Bearer ACCESS_TOKEN"
      ```

      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.
    </Note>
  </Step>

  <Step title="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.
  </Step>
</Steps>

<Note>
  **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.
</Note>

<Note>
  **`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](/conventions/dates#very-long-time-ago-placeholder).
</Note>

## 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.**

| Platform                                          | Recommended triggers                                                                                                    |
| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| **Mobile / TV apps**                              | App launch · Wake from background · Pull-to-refresh. Throttle to **once every 15–30 min** to prevent rapid-switch spam. |
| **Media servers (Plex / Kodi / Jellyfin / Emby)** | Hook into library-scan-completed events, or run when a playback session ends and the user returns to the home screen.   |
| **All platforms**                                 | Always allow a manual override (a refresh button, pull-to-refresh, or a menu item).                                     |
| **Never**                                         | Unconditional polling timers without active user interaction — no `setInterval(sync, 60_000)` in the background.        |

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

<AccordionGroup>
  <Accordion title="One sync write per user at a time — 20-second lock">
    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`:

    ```json theme={"theme":{"light":"github-light","dark":"vesper"}}
    {
      "error": "rate_limit",
      "error_description": "Another sync is in progress for this user, please retry later."
    }
    ```

    **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.
  </Accordion>

  <Accordion title="Long offline gaps are safe — `date_from` accepts any past timestamp">
    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](/api-reference/oauth) for how revocation works.
  </Accordion>

  <Accordion title="Items can be reclassified across types — read `simkl_type` and `anime_type`">
    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`](/api-reference/simkl/add-to-list) is needed — `/sync/history` already moved the item.
  </Accordion>

  <Accordion title="Re-added items reappear in deltas, with watch-state preserved">
    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.
  </Accordion>

  <Accordion title="`/sync/activities` is per-category — drill down for cheaper polls">
    The activities response is nested, not flat:

    ```json theme={"theme":{"light":"github-light","dark":"vesper"}}
    {
      "all": "...",
      "settings": { "all": "..." },
      "tv_shows": { "all": "...", "watching": "...", "plantowatch": "...", "hold": "...", "completed": "...", "dropped": "...", "rated_at": "...", "playback": "...", "removed_from_list": "..." },
      "movies":   { "all": "...", "plantowatch": "...", "completed": "...", "dropped": "...", "rated_at": "...", "playback": "...", "removed_from_list": "..." },
      "anime":    { "all": "...", "watching": "...", "plantowatch": "...", "hold": "...", "completed": "...", "dropped": "...", "rated_at": "...", "playback": "...", "removed_from_list": "..." }
    }
    ```

    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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="Playback retention varies by subscription tier">
    Paused playback sessions are kept for:

    | Tier      | Retention |
    | --------- | --------- |
    | Free      | 7 days    |
    | Simkl Pro | 30 days   |
    | Simkl VIP | 90 days   |

    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](/api-reference/playback) for the full lifecycle.
  </Accordion>

  <Accordion title="Playback `progress` rounds to integer on read">
    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:

    ```jsonc theme={"theme":{"light":"github-light","dark":"vesper"}}
    // POST /scrobble/pause body:
    { "progress": 75.5 }

    // GET /sync/playback response:
    { "progress": 75 }
    ```

    If you need sub-percent precision client-side, compute it from `current_position` and `runtime` instead of trusting the round-tripped `progress` field.
  </Accordion>

  <Accordion title="`409 Conflict` on `/scrobble/stop` for recently-watched items">
    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:

    ```json theme={"theme":{"light":"github-light","dark":"vesper"}}
    {
      "error": "already_watched",
      "watched_at": "2026-05-08T14:00:00Z",
      "expires_at": "2026-05-08T15:00:00Z"
    }
    ```

    **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](/guides/scrobble) for the full lifecycle.
  </Accordion>

  <Accordion title="`extended=full` without `date_from` will hurt — quantifiably">
    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.

    Always pair `extended=full` and `episode_watched_at=yes` with `date_from` so the response is just the changed slice. On Phase 1 (the only legit no-`date_from` call), don't add either flag — fetch the minimal payload first, then enrich per-item with the dedicated `/movies/{id}`, `/tv/{id}`, `/anime/{id}` calls as the user opens them.
  </Accordion>
</AccordionGroup>

## Common write operations

<AccordionGroup>
  <Accordion title="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[]`](/conventions/standard-media-objects#anime) for details and the `not_found.shows` caveat).

    ```json theme={"theme":{"light":"github-light","dark":"vesper"}}
    {
      "movies": [
        { "ids": { "simkl": 53536 }, "watched_at": "2026-05-08T20:00:00Z" }
      ],
      "shows": [
        {
          "ids": { "simkl": 2090 },
          "seasons": [
            { "number": 1, "episodes": [{ "number": 1 }, { "number": 2 }] }
          ]
        }
      ],
      "anime": [
        { "ids": { "anidb": 9541 } }
      ]
    }
    ```
  </Accordion>

  <Accordion title="Record a rewatch — Simkl Pro / VIP only">
    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:

    ```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
    curl -X POST "https://api.simkl.com/sync/history?allow_rewatch=yes&client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
      -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "movies": [
          { "ids": { "simkl": 53536 }, "watched_at": "2026-05-10T20:00:00Z" }
        ]
      }'
    ```

    <Warning>
      **Do not enable `?allow_rewatch=yes` until you've read the full [Rewatches guide](/guides/rewatches) 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.
    </Warning>

    **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`](/api-reference/simkl/get-user-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.

    <Info>
      **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.
    </Info>

    <Card title="Rewatches guide — full walkthrough" icon="rotate" href="/guides/rewatches" horizontal>
      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).
    </Card>
  </Accordion>

  <Accordion title="Remove items from history">
    `POST /sync/history/remove` — same shape as `/sync/history`, but removes the items.
  </Accordion>

  <Accordion title="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](/conventions/list-statuses) — movies skip `watching` and `hold`):

    ```json theme={"theme":{"light":"github-light","dark":"vesper"}}
    {
      "to": "plantowatch",
      "movies": [{ "ids": { "simkl": 53536 } }]
    }
    ```
  </Accordion>

  <Accordion title="Add ratings">
    `POST /sync/ratings` — pass a 1–10 rating per item.

    ```json theme={"theme":{"light":"github-light","dark":"vesper"}}
    {
      "movies": [
        { "rating": 8, "ids": { "simkl": 53536 } }
      ]
    }
    ```
  </Accordion>

  <Accordion title="Always batch writes">
    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×.
  </Accordion>
</AccordionGroup>

## 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](/conventions/standard-media-objects#supported-id-keys).

## Reference implementation

<Card title="Two-phase sync in 5 languages — Node, Python, Swift, Kotlin, Dart" icon="code" horizontal>
  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.
</Card>

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:

```
state.lastSync   → the watermark string, e.g. "2026-05-22T22:00:52Z"
state.cache      → your local copy of items, keyed by type:
                   { shows: [...], movies: [...], anime: [...] }
```

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

<Note>
  **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).
</Note>

<CodeGroup>
  ```js Node theme={"theme":{"light":"github-light","dark":"vesper"}}
  // === CONFIG — drop the types your app doesn't use ===
  const SUPPORTED_TYPES = ['shows', 'movies', 'anime'];

  // === Boilerplate: auth + a tiny GET helper ===
  const PARAMS  = `client_id=${CLIENT_ID}&app-name=my-app-name&app-version=1.0`;
  const HEADERS = {
    'User-Agent':    'my-app-name/1.0',
    'Authorization': `Bearer ${ACCESS_TOKEN}`,
  };
  const api = (path) => fetch(
    `https://api.simkl.com${path}${path.includes('?') ? '&' : '?'}${PARAMS}`,
    { headers: HEADERS }
  ).then(r => r.json());

  // === First run — pull the user's full library, type by type ===
  async function initialSync(state) {
    for (const type of SUPPORTED_TYPES) {
      const response = await api(`/sync/all-items/${type}`);
      state.cache[type] = response[type] ?? [];        // unwrap { shows: [...] } → just the array
    }
    // Save the watermark so future polls only fetch what's changed since now.
    const activities = await api('/sync/activities');
    state.lastSync = activities.all;
  }

  // === Every poll after that — fetch only the delta ===
  async function sync(state) {
    if (!state.lastSync) return initialSync(state);    // first run ever? bootstrap

    // Cheap: did anything change since our watermark?
    const activities = await api('/sync/activities');
    if (activities.all === state.lastSync) return;     // nothing changed — done

    // One type configured? Use the smaller per-type endpoint.
    // Two or three? One bare call covers them all.
    const path = SUPPORTED_TYPES.length === 1
      ? `/sync/all-items/${SUPPORTED_TYPES[0]}`
      : `/sync/all-items`;
    const since = encodeURIComponent(state.lastSync);
    const delta = await api(`${path}?date_from=${since}`);

    // Merge the changes into our local cache, type by type.
    for (const type of SUPPORTED_TYPES) {
      if (delta[type]) {
        state.cache[type] = mergeItems(state.cache[type] ?? [], delta[type]);
      }
    }

    // Move the watermark forward.
    state.lastSync = activities.all;
  }
  ```

  ```python Python theme={"theme":{"light":"github-light","dark":"vesper"}}
  import requests
  from urllib.parse import urlencode

  # === CONFIG — drop the types your app doesn't use ===
  SUPPORTED_TYPES = ('shows', 'movies', 'anime')

  # === Boilerplate: auth + a tiny GET helper ===
  PARAMS = {
      'client_id':   CLIENT_ID,
      'app-name':    'my-app-name',
      'app-version': '1.0',
  }
  HEADERS = {
      'User-Agent':    'my-app-name/1.0',
      'Authorization': f'Bearer {ACCESS_TOKEN}',
  }

  def api(path, extra=None):
      return requests.get(
          f'https://api.simkl.com{path}',
          params={**PARAMS, **(extra or {})},
          headers=HEADERS,
      ).json()

  # === First run — pull the user's full library, type by type ===
  def initial_sync(state):
      for type_ in SUPPORTED_TYPES:
          response = api(f'/sync/all-items/{type_}')
          state['cache'][type_] = response.get(type_, [])   # unwrap { shows: [...] }

      # Save the watermark so future polls only fetch what's changed since now.
      activities = api('/sync/activities')
      state['last_sync'] = activities['all']

  # === Every poll after that — fetch only the delta ===
  def sync(state):
      if not state.get('last_sync'):
          return initial_sync(state)                        # first run ever? bootstrap

      # Cheap: did anything change since our watermark?
      activities = api('/sync/activities')
      if activities['all'] == state['last_sync']:
          return                                            # nothing changed — done

      # One type configured? Use the smaller per-type endpoint.
      # Two or three? One bare call covers them all.
      path = f'/sync/all-items/{SUPPORTED_TYPES[0]}' if len(SUPPORTED_TYPES) == 1 else '/sync/all-items'
      delta = api(path, {'date_from': state['last_sync']})

      # Merge the changes into our local cache, type by type.
      for type_ in SUPPORTED_TYPES:
          if type_ in delta:
              state['cache'][type_] = merge_items(state['cache'].get(type_, []), delta[type_])

      # Move the watermark forward.
      state['last_sync'] = activities['all']
  ```

  ```swift Swift (iOS) theme={"theme":{"light":"github-light","dark":"vesper"}}
  import Foundation

  // === CONFIG — drop the types your app doesn't use ===
  let SUPPORTED_TYPES = ["shows", "movies", "anime"]

  struct SimklClient {
      let clientId: String
      let accessToken: String
      private let appName = "my-app-name"
      private let appVer  = "1.0"

      // Tiny GET helper — appends auth params to any path.
      private func api(_ path: String, extra: [String: String] = [:]) async throws -> [String: Any] {
          var comps = URLComponents(string: "https://api.simkl.com\(path)")!
          comps.queryItems = [
              URLQueryItem(name: "client_id",   value: clientId),
              URLQueryItem(name: "app-name",    value: appName),
              URLQueryItem(name: "app-version", value: appVer),
          ] + extra.map { URLQueryItem(name: $0.key, value: $0.value) }

          var req = URLRequest(url: comps.url!)
          req.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
          req.setValue("\(appName)/\(appVer)",  forHTTPHeaderField: "User-Agent")
          let (data, _) = try await URLSession.shared.data(for: req)
          return (try JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:]
      }

      // === First run — pull the user's full library, type by type ===
      func initialSync(_ state: AppState) async throws {
          for type in SUPPORTED_TYPES {
              let response = try await api("/sync/all-items/\(type)")
              state.cache[type] = response[type] as? [[String: Any]] ?? []
          }
          // Save the watermark so future polls only fetch what's changed since now.
          let activities = try await api("/sync/activities")
          state.lastSync = activities["all"] as? String
      }

      // === Every poll after that — fetch only the delta ===
      func sync(_ state: AppState) async throws {
          guard let lastSync = state.lastSync else {
              return try await initialSync(state)              // first run ever? bootstrap
          }

          // Cheap: did anything change since our watermark?
          let activities = try await api("/sync/activities")
          guard activities["all"] as? String != lastSync else { return }   // nothing changed

          // One type configured? Use the smaller per-type endpoint.
          // Two or three? One bare call covers them all.
          let path = SUPPORTED_TYPES.count == 1
              ? "/sync/all-items/\(SUPPORTED_TYPES[0])"
              : "/sync/all-items"
          let delta = try await api(path, extra: ["date_from": lastSync])

          // Merge the changes into our local cache, type by type.
          for type in SUPPORTED_TYPES {
              if let bucket = delta[type] as? [[String: Any]] {
                  state.cache[type] = mergeItems(state.cache[type] ?? [], bucket)
              }
          }

          // Move the watermark forward.
          state.lastSync = activities["all"] as? String
      }
  }
  ```

  ```kotlin Kotlin (Android) theme={"theme":{"light":"github-light","dark":"vesper"}}
  import okhttp3.HttpUrl.Companion.toHttpUrl
  import okhttp3.OkHttpClient
  import okhttp3.Request
  import org.json.JSONObject
  import org.json.JSONArray

  // === CONFIG — drop the types your app doesn't use ===
  val SUPPORTED_TYPES = listOf("shows", "movies", "anime")

  class SimklClient(private val clientId: String, private val accessToken: String) {
      private val appName = "my-app-name"
      private val appVer  = "1.0"
      private val http    = OkHttpClient()

      // Tiny GET helper — appends auth params to any path.
      private fun api(path: String, extra: Map<String, String> = emptyMap()): JSONObject {
          val url = "https://api.simkl.com$path".toHttpUrl().newBuilder()
              .addQueryParameter("client_id",   clientId)
              .addQueryParameter("app-name",    appName)
              .addQueryParameter("app-version", appVer)
              .apply { extra.forEach { (k, v) -> addQueryParameter(k, v) } }
              .build()
          val req = Request.Builder().url(url)
              .header("Authorization", "Bearer $accessToken")
              .header("User-Agent",    "$appName/$appVer")
              .build()
          return JSONObject(http.newCall(req).execute().use { it.body!!.string() })
      }

      // === First run — pull the user's full library, type by type ===
      suspend fun initialSync(state: AppState) {
          for (type in SUPPORTED_TYPES) {
              val response = api("/sync/all-items/$type")
              state.cache[type] = response.optJSONArray(type) ?: JSONArray()
          }
          // Save the watermark so future polls only fetch what's changed since now.
          val activities = api("/sync/activities")
          state.lastSync = activities.optString("all").ifEmpty { null }
      }

      // === Every poll after that — fetch only the delta ===
      suspend fun sync(state: AppState) {
          val lastSync = state.lastSync ?: return initialSync(state)   // first run ever?

          // Cheap: did anything change since our watermark?
          val activities = api("/sync/activities")
          if (activities.optString("all") == lastSync) return          // nothing changed

          // One type configured? Use the smaller per-type endpoint.
          // Two or three? One bare call covers them all.
          val path = if (SUPPORTED_TYPES.size == 1)
              "/sync/all-items/${SUPPORTED_TYPES[0]}"
          else
              "/sync/all-items"
          val delta = api(path, mapOf("date_from" to lastSync))

          // Merge the changes into our local cache, type by type.
          for (type in SUPPORTED_TYPES) {
              if (delta.has(type)) {
                  state.cache[type] = mergeItems(state.cache[type] ?: JSONArray(), delta.getJSONArray(type))
              }
          }

          // Move the watermark forward.
          state.lastSync = activities.optString("all")
      }
  }
  ```

  ```dart Dart (Flutter) theme={"theme":{"light":"github-light","dark":"vesper"}}
  import 'dart:convert';
  import 'package:http/http.dart' as http;

  // === CONFIG — drop the types your app doesn't use ===
  const SUPPORTED_TYPES = ['shows', 'movies', 'anime'];

  class SimklClient {
    final String clientId;
    final String accessToken;
    static const _appName = 'my-app-name';
    static const _appVer  = '1.0';

    SimklClient({required this.clientId, required this.accessToken});

    // Tiny GET helper — appends auth params to any path.
    Future<Map<String, dynamic>> api(String path, [Map<String, String>? extra]) async {
      final uri = Uri.https('api.simkl.com', path, {
        'client_id':   clientId,
        'app-name':    _appName,
        'app-version': _appVer,
        ...?extra,
      });
      final res = await http.get(uri, headers: {
        'Authorization': 'Bearer $accessToken',
        'User-Agent':    '$_appName/$_appVer',
      });
      return jsonDecode(res.body) as Map<String, dynamic>;
    }

    // === First run — pull the user's full library, type by type ===
    Future<void> initialSync(AppState state) async {
      for (final type in SUPPORTED_TYPES) {
        final response = await api('/sync/all-items/$type');
        state.cache[type] = (response[type] as List?) ?? [];
      }
      // Save the watermark so future polls only fetch what's changed since now.
      final activities = await api('/sync/activities');
      state.lastSync = activities['all'] as String?;
    }

    // === Every poll after that — fetch only the delta ===
    Future<void> sync(AppState state) async {
      if (state.lastSync == null) return initialSync(state);            // first run ever?

      // Cheap: did anything change since our watermark?
      final activities = await api('/sync/activities');
      if (activities['all'] == state.lastSync) return;                  // nothing changed

      // One type configured? Use the smaller per-type endpoint.
      // Two or three? One bare call covers them all.
      final path = SUPPORTED_TYPES.length == 1
          ? '/sync/all-items/${SUPPORTED_TYPES[0]}'
          : '/sync/all-items';
      final delta = await api(path, {'date_from': state.lastSync!});

      // Merge the changes into our local cache, type by type.
      for (final type in SUPPORTED_TYPES) {
        if (delta.containsKey(type)) {
          state.cache[type] = mergeItems(state.cache[type] ?? [], delta[type] as List);
        }
      }

      // Move the watermark forward.
      state.lastSync = activities['all'] as String?;
    }
  }
  ```
</CodeGroup>

<Tip>
  **Single-type and narrow-surface apps can poll an even cheaper signal** than `activities.all` — see the [drill-down accordion above](#edge-cases-and-gotchas) (*"`/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`.
</Tip>

## 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}`](/api-reference/simkl/get-movie), [`GET /tv/{id}`](/api-reference/simkl/get-tv-show), or [`GET /anime/{id}`](/api-reference/simkl/get-anime). Detail endpoints don't need a token and are Cloudflare-cached. If you only have an external ID, resolve it first via [`GET /redirect`](/api-reference/simkl/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:

<CardGroup cols={3}>
  <Card title="GET /sync/activities" icon="clock-rotate-left" href="/api-reference/simkl/get-activities">
    Per-type, per-status timestamps — your incremental-sync gate.
  </Card>

  <Card title="GET /sync/all-items" icon="arrows-rotate" href="/api-reference/simkl/get-all-items">
    Read library items. `{type}` and `{status}` are optional — multi-type, single-type, or single-bucket.
  </Card>

  <Card title="POST /sync/history" icon="circle-check" href="/api-reference/simkl/add-to-history">
    Mark items as watched — movies, episodes, or whole seasons.
  </Card>

  <Card title="POST /sync/history/remove" icon="circle-xmark" href="/api-reference/simkl/remove-from-history">
    Undo a watched mark with the same payload shape.
  </Card>

  <Card title="POST /sync/add-to-list" icon="list-check" href="/api-reference/simkl/add-to-list">
    Move items between Watching / Plan to Watch / Hold / Dropped / Completed.
  </Card>

  <Card title="POST /sync/ratings" icon="star" href="/api-reference/simkl/add-ratings">
    Add or update a 1–10 user rating per item.
  </Card>

  <Card title="POST /sync/ratings/remove" icon="star-half-stroke" href="/api-reference/simkl/remove-ratings">
    Clear user-set ratings.
  </Card>
</CardGroup>

## 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](/guides/scrobble). To **read** saved pause points (e.g. for a "Continue Watching" rail), use [`GET /sync/playback`](/api-reference/simkl/get-playback-sessions) (or narrow with `/sync/playback/:type`) — see [How playbacks work](/api-reference/playback) for the full picture.
