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

# Custom Lists

> Read user-curated Custom Lists from the API. Beta, read-only, and requires a Simkl PRO or VIP access token.

**Custom Lists are user-curated collections of titles** — themed groupings like *"Best 90s sci-fi"*, *"Watched with my kids"*, or *"Recommended by Anna"* — created by users in the web UI at [simkl.com/lists/](https://simkl.com/lists/).

They are **distinct from the [five-status Watchlist](/conventions/list-statuses)** (`watching`, `plantowatch`, `hold`, `dropped`, `completed`) that the API has always exposed. A title can be on the user's Watchlist with a status AND on one or more Custom Lists at the same time, independently.

<Note>
  **Reading Custom Lists is now available, in beta.** Two endpoints are live: one for a user's lists, one for a list's contents. Creating, editing and deleting lists, and adding or removing items, remain web-only for now.

  Being beta, the response shape may still change. Anything that would break a client is announced on the [changelog](/changelog) and in the [Discord](https://discord.gg/MJsWNE4) `#api` channel.
</Note>

<Warning>
  **This is an AUTH V2 endpoint, and a PRO and VIP feature.** It is the first API that requires [AUTH V2](/api-reference/auth-v2) — a V1 `client_id` cannot reach it, so [migrating](/guides/migrating-v1-to-v2) is a prerequisite rather than a nice-to-have. There is also no anonymous access. Every call needs an `Authorization: Bearer ...` token belonging to a Simkl **PRO or VIP** account. Users upgrade at [simkl.com/vip](https://simkl.com/vip/).

  Three different failures look superficially similar, and they need different fixes:

  | What is wrong | What you get |
  | - | - |
  | **No token at all** on an [AUTH V2](/api-reference/auth-v2) app | `401` with `user_token_required`. Attach the header. |
  | **An [AUTH V1](/api-reference/auth-v1) token** | `403` with [`oauth2_token_required`](/conventions/errors#oauth2-token-required). The token is valid — it is the wrong generation. Only a V2 grant works here. |
  | **Token present, account is free** | **`200`** carrying `premium_only` instead of list data — see [the trap](#the-200-that-is-not-a-list) below. |

  The last one is what bites people, because a success status carrying an error body is not what anyone checks for.

  The `403` is worth handling separately from the `401`: re-authorizing the V1 app will not clear it, and refreshing will not either. The user has to authorize your V2 `client_id`, which is a different registration — see [Run both client IDs](/guides/migrating-v1-to-v2#run-both-client-ids).
</Warning>

<Card title="Still on AUTH V1? Migrate first" icon="arrow-right" href="/guides/migrating-v1-to-v2" horizontal>
  Custom Lists is the first Simkl API a V1 `client_id` cannot reach at all, so migrating is the prerequisite rather than a later cleanup. Register a separate V2 app, run both side by side, and move users across as they reconnect — your V1 app keeps working throughout.
</Card>

## The two endpoints

<CardGroup cols={2}>
  <Card title="GET /lists/user/{userId}" icon="user" href="/api-reference/simkl/get-user-lists">
    Every list belonging to one user, paginated. Another user's account returns their public lists; your own additionally returns unlisted and private ones, plus a `pinned` flag.
  </Card>

  <Card title="GET /lists/{id}" icon="list" href="/api-reference/simkl/get-list">
    One list's metadata and its items, with sorting and paging.
  </Card>
</CardGroup>

The usual shape of an integration is to call the first to discover lists, then the second for whichever one the user opens.

## Read a custom list

<Steps>
  <Step title="Fetch the user's lists" icon="user">
    ```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
    curl "https://api.simkl.com/lists/user/USER_ID?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
      -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
      -H "User-Agent: my-app-name/1.0"
    ```

    ```json theme={"theme":{"light":"github-light","dark":"vesper"}}
    {
      "pagination": { "page": 1, "limit": 50, "total_items": 2, "total_pages": 1 },
      "lists": [
        {
          "id": 123456,
          "name": "Best 90s sci-fi",
          "slug": "best-90s-sci-fi",
          "media_type": "movies",
          "type": "regular",
          "privacy": "public",
          "counts": { "items": 42, "likes": 8, "followers": 3, "comments": 0, "collaborators": 0 },
          "sort": ["position", "asc"],
          "role": "owner"
        }
      ]
    }
    ```

    Shown abbreviated — each list also carries `description`, `display_number`, `user`, `top_items` and timestamps.

    **A list's size is `counts.items`, not `total_items`.** The `total_items` at the top level belongs to the `pagination` object and counts *lists* on this page, not titles inside them.

    Viewing your own account adds a `pinned` flag to every entry and includes your private and unlisted lists.

    **Every entry says how the user relates to it, in `role`:** `owner`, `collaborator` or `follower`. By default you only get lists they own; add `followed=true` and `collaborants=true` to include the others. Followed and collaborated lists both carry someone else's `user`, so `role` is the way to tell them apart — for a "Your lists / Following / Shared with you" layout, group on it.

    One subtlety: `collaborator` is only reported when `collaborants=true` is set. A list the user both collaborates on and follows reads `follower` if you only asked for `followed=true`.
  </Step>

  <Step title="Fetch one list's items" icon="list">
    Take an `id` from the previous response:

    ```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
    curl "https://api.simkl.com/lists/123456?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0&limit=50" \
      -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
      -H "User-Agent: my-app-name/1.0"
    ```

    ```json theme={"theme":{"light":"github-light","dark":"vesper"}}
    {
      "id": 123456,
      "name": "Best 90s sci-fi",
      "media_type": "movies",
      "counts": { "items": 42, "likes": 8, "followers": 3, "comments": 0, "collaborators": 0 },
      "sort": ["position", "asc"],
      "pagination": { "page": 1, "limit": 50, "total_items": 42, "total_pages": 1 },
      "items": [
        {
          "title": "Ghost in the Shell",
          "year": 1995,
          "type": "movie",
          "ids": { "simkl_id": 53536, "slug": "ghost-in-the-shell", "imdb": "tt0113568" },
          "poster": "39/39230a3e5f9e2c1",
          "position": 1,
          "ratings": { "simkl": { "rating": 8.4, "votes": 5120, "drop_rate": "2.1%" }, "imdb": { "rating": 8.0, "votes": 372410 } },
          "genres": ["Animation", "Sci-Fi"],
          "note": {}
        }
      ]
    }
    ```

    **The list's own metadata comes back alongside `items`, at the top level** — the same fields you saw in step 1, merged in rather than nested under a `list` key. One difference: `top_items` is always empty here, because you're getting the real `items` instead.

    Omit `sort` to get the order the list owner chose. Add `extended=full` for an `overview` on each item.

    Item shape varies by media type: series carry `total_episodes`, `network` and `next_episode_date`, TV adds `total_seasons`, movies carry `budget`, `box_office` and `dvd_date`, and anime carries `anime_type` and `mapped_tvdb_seasons`. Since a list holds one media type, branch once on `media_type` rather than per item. The external ratings key follows suit — `mal` on an anime list, `imdb` everywhere else.
  </Step>

  <Step title="Page through, if the list is long" icon="arrow-right">
    Use `total_pages` from the response rather than counting yourself, and stop when you reach it.

    Remember the ceiling: `page` times `limit` cannot exceed 10000, so at `limit=500` the last reachable page is 20. A list larger than 10,000 items cannot be read to the end through pagination alone.
  </Step>
</Steps>

Both responses use the same `pagination` object, so one paging helper works for both endpoints. **Neither is a plain array** — the items live under `items` on one and `lists` on the other.

<a id="collections" />

## Lists that belong together

Some lists are parts of one collection — a franchise kept as separate *Movies* and *TV Shows* lists, or a ranking split by decade. On simkl.com they appear as buttons above the list.

Users build a collection on the website by giving their lists the same title, each ending in a different label in parentheses — `Star Wars Franchise (Movies)` and `Star Wars Franchise (TV Shows)`. The label can be anything: a media type, a year, a genre. See [Custom List Collections](https://docs.simkl.org/how-to-use-simkl/core-features/watchlists-and-custom-lists/custom-lists/custom-list-collections) in the Simkl help center for the full how-to.

`GET /lists/{id}` reports the collection in `collection`:

```json theme={"theme":{"light":"github-light","dark":"vesper"}}
"collection": {
  "name": "Star Wars Franchise",
  "lists": [
    { "id": 2731, "name": "Movies",   "media_type": "movies" },
    { "id": 2733, "name": "TV Shows", "media_type": "tv" }
  ]
}
```

* **`collection.name`** is the shared title; each entry's **`name`** is its label — the text in parentheses, not the list's full name.
* **The list you requested is in `lists` too**, so you can render the tabs straight from this array and mark the current one by `id`.
* A favourites list's collection is the owner's other favourites lists, one per media type.
* **`collection` is `null` when the list stands alone** — the common case, so check before rendering tabs.

Each entry is a normal list: open a tab with `GET /lists/{id}` using its `id`. Only the one the user opens needs fetching — see [Fetch lazily, cache locally](#fetch-lazily-cache-locally).

<a id="fetch-lazily-cache-locally" />

## Fetch lazily, cache locally

Custom Lists are the easiest endpoint on the API to over-call, because the natural UI — a screen of lists, each opening into its items — invites a fetch per screen. Three rules keep an app that browses lists constantly down to a handful of requests a day.

### 1. Render the lists screen without fetching any list

[`GET /lists/user/{userId}`](/api-reference/simkl/get-user-lists) already returns everything a list card needs: `name`, `counts.items`, and `top_items` — up to five poster previews, in list order.

**So do not call [`GET /lists/{id}`](/api-reference/simkl/get-list) for each list to build the index.** One request renders the whole screen. Fetch a list's items only when the user actually opens it.

### 2. Cache each list, and serve the cache on back-navigation

The pattern that generates the most wasted calls is ordinary browsing:

```
lists → open "Best 90s sci-fi" → tap a movie → back → tap another → back → tap another → back
```

Every **back** is a navigation event, not a data event. Nothing changed while the user was reading a synopsis, so re-fetching the list each time turns one list view into four identical requests — against an allowance that belongs to the user.

Keep the response keyed by list `id` plus whatever `sort`, `direction` and paging you requested, and render from it until you have a reason to believe it is stale. A cache that only survives until the next screen transition is not a cache.

### 3. Refetch only when something says the list changed

Two signals, in increasing precision:

| Signal | Tells you |
| - | - |
| `custom_lists.lists.all` in [`/sync/activities`](/api-reference/simkl/get-activities) | Something, somewhere in the user's lists, changed |
| `updated_at` on each list from `GET /lists/user/{userId}` | **Which** list changed |

So the cheap loop is: poll activities → if the bucket moved, refetch the *index* → compare each list's `updated_at` against your cached copy → refetch only the lists whose timestamp moved, and only when the user opens them.

Beyond that, refresh on an explicit pull-to-refresh and nothing else. There is no need for a timer.

<Warning>
  **Auto lists need the opposite instinct.** Their contents are rebuilt about once a day and [the activity timestamp does not move when that happens](/guides/sync#custom-lists-have-their-own-timestamps) — so a signal-driven client would never refresh them at all.

  Refresh an auto list **at most once a day**, and only when the user opens it. Do not refresh one on every open, and do not put it on a timer: the data underneath changes daily at most, so anything more frequent is guaranteed to return what you already have.
</Warning>

<a id="the-200-that-is-not-a-list" />

## The 200 that is not a list

This is the bug most likely to reach production, so handle it before you write anything else.

A caller whose **account** is not PRO or VIP does **not** get a `403`. They get **HTTP 200** carrying a `premium_only` body instead of list data:

```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
  "error": "premium_only",
  "message": "Custom list items are available to Simkl PRO and VIP members only.",
  "item": {
    "title": "Upgrade to Simkl PRO/VIP to unlock this list",
    "description": "Subscribe to Simkl PRO or VIP to view this list",
    "poster": "https://simkl.in/poster_upgrade.webp"
  }
}
```

**Check the status first, then check for an `error` key on a 200 before reading `items`:**

```js theme={"theme":{"light":"github-light","dark":"vesper"}}
const res  = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
const body = await res.json();

// 401 user_token_required, 403 private_list, 404 url_failed, 400 wrong_parameter
// all arrive here with no `items` either.
if (!res.ok) {
  handleApiError(res.status, body.error);
  return;
}

// A 200 is not automatically a list.
if (body.error === "premium_only") {
  showUpgradePrompt(body.item);   // renderable as-is
  return;
}

renderList(body.items);           // GET /lists/{id}; use body.lists for the user endpoint
```

The `!res.ok` branch is not boilerplate here. Because `premium_only` arrives as a `200`, it is tempting to treat *every* non-200 as impossible and skip the check — but `401`, `403`, `404` and `400` are all reachable on these endpoints, and none of them carries `items` either.

Three details that each break a naive client:

* **`error` is lowercase** `premium_only`.
* **There is no `items` array and no `pagination` object.** This is a different shape, not a degraded one, so `items` is `undefined` rather than `[]`. A client that maps over it throws; a client that checks `length` silently renders nothing.
* **`item` is singular and renderable** — real title, description and poster URL. Rendering it as an ordinary card is the simplest way to explain the empty list to the user.

Skip the check and your user sees one fake entry titled *"Upgrade to Simkl PRO/VIP"* sitting in what looks like their list.

<Note>
  **A list that does not exist returns the same `premium_only` body.** For a free or anonymous caller the tier check runs before the list is looked up, so a bad list ID is indistinguishable from a paywalled one. With a PRO or VIP token you get a normal `404`.
</Note>

## Paging and sorting

Both endpoints take `limit` (default 50, max 500), `page`, and `direction`.

<Warning>
  **Out-of-range values are clamped silently.** `limit=99999` returns 500 items with a `200`, and `page=0` returns page 1. A wrong *type* like `limit=abc` is normally a `400` instead — but see the caveat below.

  Also note `page` times `limit` cannot exceed 10000, so at the default limit the last reachable page is 200. A list with 30,000 items cannot be read to the end this way.
</Warning>

<Warning>
  **On `GET /lists/{id}`, a malformed `limit` or `page` may be silently ignored rather than rejected.** That endpoint sits behind an edge cache that coerces those two parameters itself before deciding what to serve. On a cache hit, `limit=abc` is replaced with the default and answered `200`; on a miss, the same request is a `400`.

  Verified against production — the identical URL returns `200` or `400` depending only on whether that list is currently cached, which is not something your client can see or control.

  **Validate paging input on your side rather than relying on the API to reject it.** `direction` is always checked, and `GET /lists/user/{userId}` is not edge-cached, so those two behave predictably.
</Warning>

The two endpoints validate `sort` differently, which is deliberate but easy to trip over:

| Endpoint | `sort` behaviour |
| - | - |
| `GET /lists/user/{userId}` | Fixed set — `updated`, `created`, `name`, `likes`, `followers`, `items`, `popularity`. Anything else is a `400 wrong_parameter`. |
| `GET /lists/{id}` | Free-form. An unrecognised value is **accepted**, and echoed straight back in the response's `sort` field. |

So a typo in `sort` fails loudly on one endpoint and silently on the other.

<Warning>
  **On `GET /lists/{id}` the `sort` field in the response is not proof your sort was applied.** It echoes the token you sent, including one that means nothing — `?sort=banana` comes back as `["banana","desc"]` with a `200`. There is no error to catch a typo with, so verify against the returned order rather than against that field.

  **Passing `sort` at all also changes the direction default.** With no `sort`, the list's own stored direction applies. The moment you send one, the default becomes `desc` — so `?sort=position` returns `["position","desc"]` on a list whose own order is `["position","asc"]`, quietly reversing it. Send `direction=asc` alongside if you meant to keep the original order.
</Warning>

On `GET /lists/{id}`, omitting `sort` entirely is usually right — it uses the order the list owner chose, in the direction they chose.

Two viewer-relative tokens get rewritten before use, and the rewritten form is what comes back: `my-rating` becomes `user-rating`, and `last-watched` becomes `user-last-watched`.

<Note>
  **`?followed=true` and `?collaborants=true` must be the literal string `true`.** `1`, `yes` and `on` are ignored without error, so a filter that seems to do nothing is usually this.
</Note>

## What `privacy` actually controls

| Value | Global browse | `GET /lists/user/{userId}` | `GET /lists/{id}` |
| - | - | - | - |
| `public` | Listed | Returned to anyone | Anyone |
| `unlisted` | Hidden | **Owner only** | Anyone holding the ID |
| `private` | Hidden | Owner and collaborators only | Owner and collaborators, else `403 private_list` |

**`unlisted` means reachable by link, but not enumerable.** Anyone given the list ID can read it, but it will not show up when you list that user's lists — only the owner sees their own unlisted lists there.

The practical consequence for a library view: reading **another** user's lists returns their public ones, while reading the **signed-in user's own** returns public, unlisted and private together, each tagged with its `privacy` value. If you render someone else's library you cannot accidentally expose an unlisted list, because it is never in the response.

`private` is the most restricted: it appears only in the owner's own library and to collaborators, and reading it by ID as anyone else is a `403 private_list`.

<Note>
  **Two more behaviours worth knowing on `GET /lists/{id}`.**

  `?extended=full` adds an `overview` field to every item. It is PRO-only on both sides — a free caller does not get `overview` even if the parameter is present, and the response is cached as a separate variant, so mixing the two does not poison either.

  Sorts that depend on watch data resolve against the **list owner**, not the caller. That is deliberate — it means a shared list looks identical to everyone who opens it, rather than reshuffling per viewer.
</Note>

## Account limits

These are limits on what a user can build. They are enforced when a list is saved on the website, so the read API never returns them — but they explain why a list stops accepting items.

| Tier | Items per list | Lists | Items across all lists |
| - | - | - | - |
| PRO | 2,000 | 30 | 60,000 |
| VIP | 10,000 | 1,000 | 500,000 |

There are three separate ceilings and a user can hit any of them independently: too many items in one list, too many lists, or too many items across everything.

## Still web-only

* Creating, renaming and deleting lists
* Adding and removing items
* Reordering, notes, collaborators and privacy changes

These are on the roadmap. The Watchlist sync endpoints are unaffected and continue to work unchanged — Custom Lists are an additional layer, not a replacement.

<Warning>
  **Write verbs are ignored, not rejected.** `POST`, `PUT`, `PATCH` and `DELETE` on `/lists/{id}` all return **`200` with the list**, exactly as a `GET` would. There is no `405` and no `Allow` header.

  Nothing is created, changed or deleted — the method is simply not part of the routing — but `DELETE /lists/123456` answering `200` reads as success, and a client that trusts the status will think the list is gone. Treat these endpoints as `GET`-only and don't infer write support from a `200`.
</Warning>

## Common questions

<AccordionGroup>
  <Accordion title="Should I use Custom Lists or the Watchlist?">
    Different jobs. The [five-status Watchlist](/conventions/list-statuses) is where *every* tracked title lives, with watch state — that is what you want for "show me the user's library" and for anything involving progress. Custom Lists are user-made groupings on top, and most users have few or none.

    If you are building a library view, start with [`GET /sync/all-items`](/api-reference/simkl/get-all-items). Add Custom Lists when you specifically want to surface the user's own curation.
  </Accordion>

  <Accordion title="Why does an anonymous call return 200 instead of 401?">
    Because the response carries content rather than just refusing: a renderable card an app can show in place of the list, without special-casing anything. That is convenient if you handle it and a trap if you do not, which is why it has [its own section](#the-200-that-is-not-a-list) above.

    A genuine missing-token error looks different — `401` with `user_token_required`, documented in [Errors](/conventions/errors#user-token-required).
  </Accordion>

  <Accordion title="Can I scrape simkl.com/lists/ pages instead?">
    **No. Scraping simkl.com is not allowed.**

    Everyone doing it will be permanently blocked from the website and their account terminated.
  </Accordion>

  <Accordion title="Why does one auto list return 400?">
    It is set to **random order**. An auto list can be told to shuffle its results on every view, and the API does not serve those: `GET /lists/{id}` answers `400` with the message *"Random auto lists are not supported by the API"*.

    The reason is caching. List responses are cached for hours, so the API would hand every caller the same "random" sample until the cache expired — not random at all. Rather than pretend, it refuses.

    Treat it like any other list you cannot show: skip it, or link the user to the list on simkl.com, where it shuffles as intended.
  </Accordion>

  <Accordion title="Do Custom Lists count against my rate limit?">
    Yes, the same as any other endpoint — see [Rate limits](/resources/rate-limits). List reads are not in the exempt set, and the allowance they spend is the **user's**, shared with every other app they run.

    [Fetch lazily, cache locally](#fetch-lazily-cache-locally) has the three rules that matter: render the index from `top_items` without opening any list, serve the cache on back-navigation, and refetch only when `updated_at` says a list actually changed.

    **Poll [`/sync/activities`](/api-reference/simkl/get-activities) rather than the lists themselves.** It carries `custom_lists.lists.all`, which moves whenever any of the user's lists changes — see [Custom Lists have their own timestamps](/guides/sync#custom-lists-have-their-own-timestamps). One cheap call tells you whether a refetch is needed at all, instead of pulling lists on a timer to find out nothing changed.

    It also breaks down by kind — `favorites`, `recommendations`, `regular`, `auto` — so a user favouriting one show costs you one list refresh rather than a full re-read. If your UI only surfaces favourites, watch that one bucket and ignore the rest.
  </Accordion>

  <Accordion title="Can I read another user's public lists?">
    Yes, by their user ID, as long as *your* token is PRO or VIP — the tier check is on the caller, not the list owner. You will see their public and unlisted lists; private ones are visible only to the owner and collaborators, and return `403 private_list` otherwise.
  </Accordion>
</AccordionGroup>

## Reference

<CardGroup cols={2}>
  <Card title="Custom lists API" icon="list" href="/api-reference/lists">
    Endpoint overview, shared parameters, and the `premium_only` response.
  </Card>

  <Card title="AUTH V2" icon="shield-halved" href="/api-reference/auth-v2">
    How to get the user token these endpoints require.
  </Card>

  <Card title="Sync guide" icon="arrows-rotate" href="/guides/sync">
    Two-phase model, `date_from` semantics, deletion reconciliation.
  </Card>

  <Card title="Watchlist statuses" icon="list-check" href="/conventions/list-statuses">
    The five status values and per-type rules.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.