Skip to main content

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.

null in a Simkl response is never noise — it’s one of five specific signals. This page is the master reference; schema descriptions link back here by type.

Type 1

Never happened yet — event hasn’t fired

Type 2

Doesn’t apply — key omitted entirely

Type 3

Empty result — whole response null

Type 4

Unknown — data not on file

Type 5

End state — journey complete

Type 1 — Never happened yet

Field present, value null — the event hasn’t fired for this user yet, but the field is a normal part of the model. Once it happens, the value flips to a real value and never reverts to null.
Where you’ll see it
EndpointFieldWhen it’s null
GET /sync/activitiesevery bucket timestampUser has never had activity in that bucket
GET /sync/all-itemslast_watched_atPlantowatch items — user hasn’t watched yet
GET /sync/all-itemsuser_rated_at, user_ratingItems the user hasn’t rated
GET /sync/all-itemslast_watchedShows the user hasn’t started
Handling
// ✓ Correct
if (item.last_watched_at !== null) {
  showLastWatched(item.last_watched_at);
}

// ✗ Wrong — gives a "watched on Jan 1 1970" UI
showLastWatched(item.last_watched_at ?? '1970-01-01T00:00:00Z');

Type 2 — Doesn’t apply to this type

Key omitted entirely — not set to null, the property doesn’t exist on this object. Iterating keys won’t return it; 'fieldName' in obj returns false.
Where you’ll see it
EndpointTypeWhat’s omitted
GET /sync/activitiesmovies blockwatching and hold keys (movies can’t be in those statuses)
GET /sync/all-itemsMovie recordsEpisode-related fields (seasons, last_watched, etc.)
Movie / TV recordsAnime-only IDs (mal, anidb, anilist, kitsu) and anime_type
GET /tv/episodes/{id}TV specialsseason AND episode both omitted on type: "special" items
GET /anime/episodes/{id}Every anime episodeseason omitted (AniDB numbers anime sequentially, no per-season concept)
GET /anime/{id}TV-classified showsrelations array omitted entirely (anime catalog only — TV records skip this field)
GET /sync/all-items without ?memos=yesevery entrymemo key omitted (gated on the query param)
GET /sync/all-items without ?next_watch_info=yesevery entrynext_to_watch_info key omitted
GET /sync/all-items without ?allow_rewatch=yesrewatch-session rowsis_rewatch, rewatch_id, rewatch_status keys omitted entirely; only the canonical entry returns
GET /sync/all-items without ?extended=full_anime_seasonsanime entriesmapped_tvdb_seasons array omitted
GET /sync/all-items without ?episode_tvdb_id=yesepisode rowstvdb block on each episode omitted
See Watchlist statuses for the full per-type matrix.
/sync/all-items/anime entries wrap in show:, by design. Each item in the anime array nests its media object under a key called show:, not anime:. This is intentional cross-catalog compatibility: apps that source their data from TMDB or TVDB treat anime as shows (those catalogs have no separate “anime” concept), so the show: wrapper keeps Simkl’s sync responses drop-in compatible with code written against TMDB/TVDB-shaped data. Anime-only fields (anime_type, mal/anidb/anilist/kitsu IDs) sit alongside on the outer item and on show.ids respectively.
// ✓ Correct — same iteration pattern works for shows/, anime/, and
//   future media types that follow the show-shaped TMDB/TVDB model
const list = await fetch('/sync/all-items/anime').then(r => r.json());
list.anime.forEach(item => {
  const metadata = item.show;          // anime use `show:` for the same
                                       // reason TVDB indexes anime under shows
  const ids = metadata.ids;            // ids.simkl, ids.mal, ids.anidb, ...
  const animeFormat = item.anime_type; // anime-specific outer-item field
});
Handling
// ✓ Correct
if ('watching' in activities.tv_shows) {
  // TV has a watching bucket
}
// ✗ Wrong — activities.movies.watching is undefined, not null
if (activities.movies.watching === null) {
  /* never true */
}

Type 3 — Empty result set

The whole response is null or an empty array — the endpoint accepted the request but there are no items to return. The URL is valid; the call returns 200; the body is just empty.
Where you’ll see it
EndpointWhen it’s empty
GET /sync/all-items/movies/watchingAlways — movies can’t have this status, the route is reachable but the data set is structurally empty
GET /sync/all-items/movies/holdSame
GET /sync/all-items/{type}/{status}Any bucket the user simply has no items in
POST /search/randomReturns {"error": "not_found"} when no item matches the filters
Handling
// ✓ Correct
const items = await fetch('/sync/all-items/movies/watching').then(r => r.json());
if (!items) return;        // empty bucket — bail early

// ✗ Wrong — TypeError if response is null
const items = await fetch(...).then(r => r.json());
items.movies.forEach(...);

Type 4 — Unknown / data not on file

Field present, value null — the data point is supposed to have a value but Simkl genuinely doesn’t have it yet. Render a placeholder; don’t display the literal null.
Where you’ll see it
EndpointFieldWhen it’s null
GET /tv/airingdateAir date not on file yet
GET /tv/episodes/{id}descriptionEpisode synopsis unknown
GET /tv/episodes/{id}imgEpisode still not on file
GET /movies/{id}ratings.imdb, etc.No external rating known
GET /anime/{id}en_titleLocalized English title not on file (e.g. Death Note returns null)
GET /tv/{id}, /anime/{id}networkMirrored from the upstream TVDB record. null when TVDB has no network on file (mostly recent anime).
GET /tv/{id}, /anime/{id}airsRecurring schedule not on file (older shows, anime films)
GET /tv/{id}, /anime/{id}statusCatalog Status column blank — rare; status is normally one of tba, ended, airing
Catalog endpointsruntimeRuntime not tracked
network reflects the upstream catalog (TVDB). The value is what TVDB classifies the record under — for streaming-only shows TVDB lists the platform as the “network” ("Prime Video", "Netflix", "Apple TV+"), for traditional broadcast it’s the broadcaster ("HBO", "AMC", "CBS"). Don’t infer distribution model from this field; treat it as an opaque display string from the source catalog.
null vs empty string. Older Simkl records sometimes return an empty string "" for a field rather than null (e.g. en_title: "" on classic anime, before the API standardized on null). Both mean Type 4 — data not on file. Check both:
const englishTitle = item.en_title || 'No English title on file';
// matches null AND empty string
memo: {} is a Type 4 variant. On GET /sync/all-items?memos=yes, items the user has set a memo on return:
{ "memo": { "text": "Loved this season", "is_private": false } }
Items without a memo return an empty object:
{ "memo": {} }
NOT null, NOT missing — {}. The empty-object marker keeps the key shape consistent (client code can always do item.memo.text || '' without an existence check or a null-coalesce). When ?memos=yes is NOT set, the memo key is omitted entirely (Type 2).
Handling
// ✓ Correct
<span>{episode.description ?? 'No description yet.'}</span>

// ✓ Correct — guard date-display code
<span>{episode.date ? formatDate(episode.date) : 'TBA'}</span>

Type 5 — End state reached

Field present, value null — the value used to be meaningful but the workflow has ended; the field is intentionally cleared. Render the “complete” state in your UI.
Where you’ll see it
EndpointFieldWhen it’s null
GET /sync/all-itemsnext_to_watchShow in completed — no further episodes
GET /sync/all-items ?next_watch_info=yesnext_to_watch_infoSame — completed show, nothing remaining
Handling
// ✓ Correct
{show.next_to_watch
  ? <Badge>Next: {show.next_to_watch}</Badge>
  : <Badge variant="success">All caught up</Badge>}

Empty / Missing response shape — by endpoint

The API uses five different shapes for “this request was valid but there’s no data”. The table below tells you what to expect at each endpoint so a single parsing path can handle them all.

The five shapes you’ll meet

200 [ ]

Empty array. Safe to iterate.

200 null

Top-level null. r.json() returns None.

200 { }

Empty object. len() is 0.

301 redirect

Answer in Location: header. No body.

200 fallthrough

Server returned unrelated discover data instead of an error.
Status-code lies. Five endpoints return 200 OK with an empty shape rather than 404 Not Found. Branch on the body shape, not just the status — see the rows tagged in the table below.Conversely, three CDN endpoints (/calendar/..., /discover/...) return 404 with a plain string body, not the JSON error envelope the rest of the API uses. r.json() will raise on these — wrap the parse.

Per-endpoint shape matrix

EndpointTriggerStatusBody shapeHow to handle
GET /movies/{id}unknown numeric Simkl ID200[]Empty array (Type 3). Safe to iterate.
GET /tv/{id}unknown numeric Simkl ID200[]Same.
GET /anime/{id}unknown numeric Simkl ID200[]Same.
GET /tv/episodes/{id}unknown parent show ID200[]Same.
GET /anime/episodes/{id}unknown parent anime ID200[]Same.
GET /tv/episodes/{id}non-numeric id200[]Same. No 404 for non-numeric id.
GET /anime/episodes/{id}non-numeric id200[]Same.
GET /movies/{id}non-numeric id404{error, code, message?}JSON error envelope.
GET /tv/{id}non-numeric id200object{...}Fallthrough — server returns discover data (top_aired_fanarts, etc.) instead of an error — NOT a ShowDetail shape.
GET /anime/{id}non-numeric id200object{...}Same fallthrough behavior.
GET /tv/episodes/empty id (trailing slash)400{error: "empty_id", code: 400}JSON error envelope.
GET /anime/episodes/empty id400{error: "empty_id", code: 400}Same.
GET /sync/activitiesfresh account (no activity yet)200object{} w/ all-null timestampsType 1 nulls inside, never null at top.
GET /sync/all-itemsbrand-new user (no library, no type filter)200{}Empty object. Iterating keys yields nothing.
GET /sync/all-items/{type}empty bucket200object{type: []}Object wrapper present; inner array is empty.
GET /sync/all-items/movies/watchingmovies can’t be watching200{}Empty object — not null, not []. Iterating keys yields nothing.
GET /sync/all-items/movies/holdmovies can’t be hold200{}Same.
GET /sync/all-items?memos=yesitem with no memo set200{ "memo": {} } per itemEmpty object inside the per-item shape — NOT null, NOT missing. Type 4 variant.
GET /sync/all-items/badtypebad type segment200object{...}Fallthrough — server returns a default response with cross-type data. No 400/404.
GET /sync/all-items/{type}/badstatusbad status segment200object{type: []}Falls back to “all of that type”, just empty. No 400/404.
GET /sync/ratings/{type}/{1-10}user has no ratings at this score200{}Empty object, NOT empty array.
GET /sync/ratings/{type}/0rating < 1 (out of range)200{}Same — no validation error.
GET /sync/ratings/{type}/11rating > 10 (out of range)200{}Same.
GET /sync/ratings/badtype/{N}bad type200{}Same.
GET /sync/playback/{type}empty200array[...]Returns array (possibly empty).
GET /sync/playback/badtypebad type404{error: "url_failed", code: 404}404 here, but 200 fallthrough on /sync/all-items/badtype — be aware of the asymmetry.
GET /search/{type}missing q parameter200[]Empty array.
GET /search/badtypebad type200nullTop-level null. r.json() returns None.
GET /search/idunknown external id200[]Empty array.
GET /search/idno id param at all200[]Same.
POST /search/randomempty body200object{...}Returns a random item — never empty.
POST /search/randomfilters yield no match200{error: "not_found", ...}Status is 200 but body is the error envelope. Branch on 'error' in body, not on status.
POST /search/fileempty body200nullTop-level null.
GET /ratingsbad params, no id200nullTop-level null.
GET /ratingsunknown imdb id200nullSame.
GET /ratings/{type}missing user_watchlist param200nullSame — even WITHOUT a Bearer token (auth gate is on the param).
POST /users/settingsnormal200object{user, account, connections}Always a populated object for the authed user.
POST /users/{user_id}/statsunknown user_id200object{...} w/ zero statsReturns a zero-counts record, NOT 404. (Cross-account access intentional.)
GET /users/recently-watched-background/{user_id}numeric unknown user200nullTop-level null for a numeric-but-unknown id.
GET /users/recently-watched-background/{user_id}non-numeric user_id404{error: "user_id_failed", code: 404}JSON error envelope.
GET /redirectno id at all301empty body, Location: //simkl.com301 redirect — body is empty; answer is in the header. Don’t r.json().
GET /redirectunknown external id301empty body, Location: //simkl.comSame — fallback to simkl.com root.
GET /redirectvalid id301empty body, Location: //simkl.com/<type>/<id>/<slug>Always 301 — never returns JSON.
GET /oauth/pin/{user_code}unknown user_code200object{...} (pending status)Returns the pending-PIN shape, NOT 404.
GET /calendar/{type}.json (CDN)normal200array[...]
GET /calendar/{year}/{month}/{type}.json (CDN)far-future month404plain string bodyr.json() will raise. Wrap the parse.
GET /calendar/badtype.json (CDN)bad type404plain string bodySame.
GET /discover/trending/{file}.json (CDN)normal200array[...] or object{...} (combined)
GET /discover/trending/badcombo.json (CDN)unknown filename404plain string bodySame — CDN errors are not JSON.

Defensive parser pattern

If you’re writing a generic Simkl client wrapper, this defensive pattern handles every shape above:
async function safeJson(response) {
  // CDN 404s and the redirect-without-follow are not JSON.
  if (response.status >= 300 && response.status < 400) return null;
  if (!response.headers.get('content-type')?.includes('json')) return null;
  let body;
  try { body = await response.json(); }
  catch { return null; }                     // bad-content-type 404 string
  if (body === null) return null;            // top-level null (Type 3)
  if (Array.isArray(body) && body.length === 0) return [];   // empty array
  if (typeof body === 'object' &&
      Object.keys(body).length === 0) return {};             // empty object
  if (body && typeof body === 'object' &&
      'error' in body && response.status === 200) {
    // 200-with-error-envelope (e.g. POST /search/random with no match)
    return null;
  }
  return body;
}

Pinning a shape in your code

If your integration depends on a specific row of this table, pin it as a comment in your code. When the table changes on a future doc release, your in-code reference makes the diff easier to spot during a routine dependency-update review.

Quick decision table

SymptomTypeMeansDo
Timestamp null on activities1Never happened yetDon’t show “last activity”
Key isn’t in the object2Doesn’t apply to type'key' in obj check, or skip silently
Whole response is null3Bucket is emptyRender empty state
Description/image is null4Data not on fileRender placeholder (“TBA”, ”—“)
next_to_watch null on a show5Show is completedRender “all caught up”

Watchlist statuses

Which statuses are valid per type. The reason watching and hold are omitted from movie blocks (Type 2).

Standard media objects

Field-by-field shape of Movie / Show / Anime / Episode — which fields can be null, which are always present.

Dates and timezones

ISO-8601, the Z suffix, and the 1970-01-01T00:00:01Z “very long time ago” placeholder.

Errors

How error envelopes differ from null responses, and when you’ll see each.