Skip to main content
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/. They are distinct from the five-status Watchlist (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.
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 and in the Discord #api channel.
This is an AUTH V2 endpoint, and a PRO and VIP feature. It is the first API that requires AUTH V2 — a V1 client_id cannot reach it, so migrating 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.Three different failures look superficially similar, and they need different fixes: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.

Still on AUTH V1? Migrate first

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.

The two endpoints

GET /lists/user/{userId}

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.

GET /lists/{id}

One list’s metadata and its items, with sorting and paging.
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

Fetch the user's lists

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.

Fetch one list's items

Take an id from the previous response:
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.

Page through, if the list is long

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

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 in the Simkl help center for the full how-to. GET /lists/{id} reports the collection in collection:
  • 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

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} 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} 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:
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: 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.
Auto lists need the opposite instinct. Their contents are rebuilt about once a day and the activity timestamp does not move when that happens — 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.

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:
Check the status first, then check for an error key on a 200 before reading items:
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.
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.

Paging and sorting

Both endpoints take limit (default 50, max 500), page, and direction.
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.
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.
The two endpoints validate sort differently, which is deliberate but easy to trip over: So a typo in sort fails loudly on one endpoint and silently on the other.
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.
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.
?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.

What privacy actually controls

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

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

Common questions

Different jobs. The five-status Watchlist 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. Add Custom Lists when you specifically want to surface the user’s own curation.
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 above.A genuine missing-token error looks different — 401 with user_token_required, documented in Errors.
No. Scraping simkl.com is not allowed.Everyone doing it will be permanently blocked from the website and their account terminated.
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.
Yes, the same as any other endpoint — see 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 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 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. 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.
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.

Reference

Custom lists API

Endpoint overview, shared parameters, and the premium_only response.

AUTH V2

How to get the user token these endpoints require.

Sync guide

Two-phase model, date_from semantics, deletion reconciliation.

Watchlist statuses

The five status values and per-type rules.