GuidesLETTERBOXD
Letterboxd API without approval
Letterboxd's API is by request only and turns down personal projects, so the practical way to get diary data into a web page is the member RSS feed: fetch https://letterboxd.com/USERNAME/rss/ through https://proxy.cors.dev/ and parse it with DOMParser. It's free, needs no signup or key and allows 600 requests per minute per client IP; one feed is about 100 KB and holds the latest 50 diary entries plus up to 50 lists. Letterboxd sends no Access-Control-Allow-Origin header, so the same fetch() without the proxy is blocked by CORS.
The official Letterboxd API
Letterboxd runs its API at https://api.letterboxd.com/api/v0/ for its own apps and approved partners. Access is by email to api@letterboxd.com, and the API page lists what it will not approve: data analysis, visualization or recommendation projects, LLM or GPT-related use, private or personal projects, and anything that recreates paid features. It sends everyone else to the member RSS feeds, the data export and TMDB for film metadata.
What loads through cors.dev
Letterboxd sits behind a Cloudflare bot check. The member feed, film pages, the films grid, the watchlist and list pages pass. The profile page, the diary, reviews and every other RSS path return a 403 challenge page, through the proxy and from a server without it, so build on the member feed.
| URL | Result | Size |
|---|---|---|
| /dave/rss/ | 200, RSS with 100 items | 116 KB |
| /film/parasite-2019/ | 200, HTML with JSON-LD | 334 KB |
| /film/dune-part-two/ | 200, HTML with JSON-LD | 387 KB |
| /dave/films/ | 200, HTML poster grid | 215 KB |
| /dave/watchlist/ | 200, HTML, 28 film slugs per page | 138 KB |
| /dave/lists/ | 200, HTML list index | 203 KB |
| /dave/, /dave/films/diary/, /dave/films/reviews/ | 403 Cloudflare challenge | - |
| /dave/watchlist/rss/, /dave/lists/rss/, /dave/films/rss/, list RSS | 403 Cloudflare challenge | - |
Read the diary feed
Every member has a feed at https://letterboxd.com/USERNAME/rss/. It mixes three item types, told apart by the guid prefix: letterboxd-watch- for a diary entry, letterboxd-review- for a diary entry with a review, and letterboxd-list- for a list. The film fields sit in two XML namespaces, so read them with getElementsByTagNameNS.
const LETTERBOXD = 'https://letterboxd.com';
const TMDB = 'https://themoviedb.org';
async function fetchDiary(username) {
const response = await fetch(`https://proxy.cors.dev/https://letterboxd.com/${username}/rss/`, {
credentials: 'omit',
});
if (!response.ok) {
throw new Error(`Feed request failed: ${response.headers.get('X-Cors-Error') ?? response.status}`);
}
const feed = new DOMParser().parseFromString(await response.text(), 'application/xml');
if (feed.querySelector('parsererror')) throw new Error('The feed is not valid XML');
return [...feed.getElementsByTagName('item')]
.map((item) => {
const field = (namespace, name) => item.getElementsByTagNameNS(namespace, name)[0]?.textContent ?? '';
const description = item.getElementsByTagName('description')[0]?.textContent ?? '';
const poster = new DOMParser().parseFromString(description, 'text/html').querySelector('img');
return {
film: field(LETTERBOXD, 'filmTitle'),
year: Number(field(LETTERBOXD, 'filmYear')) || null,
rating: Number(field(LETTERBOXD, 'memberRating')) || null,
watched: field(LETTERBOXD, 'watchedDate'),
rewatch: field(LETTERBOXD, 'rewatch') === 'Yes',
liked: field(LETTERBOXD, 'memberLike') === 'Yes',
tmdbId: field(TMDB, 'movieId'),
url: item.getElementsByTagName('link')[0]?.textContent ?? '',
poster: poster?.getAttribute('src') ?? '',
};
})
// Lists share the feed but carry no film fields
.filter((entry) => entry.film);
}
const diary = await fetchDiary('dave');
console.log(diary.length, diary[0]);On three busy accounts the feed held exactly 50 diary entries and 50 lists, between 88 and 116 KB. There is no paging: ?page=2 returns the same 100 items. Anything older comes from the member export.
Diary item fields
| Element | Example | Notes |
|---|---|---|
| letterboxd:filmTitle | In Waves | Missing on list items |
| letterboxd:filmYear | 2026 | Release year |
| letterboxd:memberRating | 4.0 | 0.5 to 5.0 in half steps; missing when unrated |
| letterboxd:watchedDate | 2026-10-16 | YYYY-MM-DD |
| letterboxd:rewatch | No | Yes or No |
| letterboxd:memberLike | Yes | Yes or No |
| tmdb:movieId | 947502 | TMDB movie ID |
| title | In Waves, 2026 - ★★★★ | Film, year and stars as text |
| description | HTML | Poster image, then the review text |
| dc:creator | Dave Vis | Member display name |
The namespace URIs are https://letterboxd.com for the letterboxd prefix and https://themoviedb.org for tmdb. 3 of dave's 50 entries had no rating, so treat memberRating as optional.
Show the latest films on your site
// Markup: <ul id="diary"></ul>
const list = document.querySelector('#diary');
list.replaceChildren(
...diary.slice(0, 10).map((entry) => {
const item = document.createElement('li');
const link = document.createElement('a');
const poster = document.createElement('img');
const stars = entry.rating ? '★'.repeat(Math.floor(entry.rating)) + (entry.rating % 1 ? '½' : '') : 'unrated';
link.href = entry.url;
poster.src = entry.poster;
poster.alt = '';
poster.width = 70;
poster.height = 105;
poster.loading = 'lazy';
link.append(poster, `${entry.film} (${entry.year ?? '?'})`);
item.append(link, ` ${stars}, watched ${entry.watched}`);
return item;
}),
);Posters load from a.ltrbxd.com in a plain img tag; they returned 200 with a foreign Referer, so images need no proxy.
A film's rating from its page
Film pages load through the proxy and carry a JSON-LD Movie object in a script type="application/ld+json" tag, wrapped in /* <![CDATA[ */ comments. It holds name, director, actor, genre, duration, image, countryOfOrigin and aggregateRating with ratingValue, ratingCount and reviewCount. The og:title meta tag adds the year, and the page links to the film on TMDB and IMDb.
async function fetchFilm(slug) {
const response = await fetch(`https://proxy.cors.dev/https://letterboxd.com/film/${slug}/`, {
credentials: 'omit',
});
if (!response.ok) {
throw new Error(`Film page failed: ${response.headers.get('X-Cors-Error') ?? response.status}`);
}
const page = new DOMParser().parseFromString(await response.text(), 'text/html');
const script = page.querySelector('script[type="application/ld+json"]');
if (!script) throw new Error('No structured data on this page');
// Letterboxd wraps the JSON-LD in CDATA comments
const movie = JSON.parse(script.textContent.replace('/* <![CDATA[ */', '').replace('/* ]]> */', ''));
const meta = (property) => page.querySelector(`meta[property="${property}"]`)?.getAttribute('content') ?? '';
return {
title: movie.name,
year: Number(meta('og:title').match(/\((\d{4})\)$/)?.[1]) || null,
rating: movie.aggregateRating?.ratingValue ?? null,
ratings: movie.aggregateRating?.ratingCount ?? 0,
directors: (movie.director ?? []).map((person) => person.name),
genres: movie.genre ?? [],
poster: movie.image,
backdrop: meta('og:image'),
};
}
console.log(await fetchFilm('parasite-2019'));Parasite came back as 334 KB of HTML with an average of 4.51 from 5,947,740 ratings. The three film pages we tested were 241 to 387 KB, inside the 1 MiB free-tier cap.
Letterboxd export: your whole history as CSV
- Open Settings
Sign in on letterboxd.com and open Settings.
- Open the Data tab
Click Export your data, or go straight to letterboxd.com/user/exportdata/.
- Download the ZIP
Letterboxd builds a ZIP of CSV files, including diary.csv, ratings.csv, watched.csv and watchlist.csv, plus your reviews, lists and profile.
The export is the only way to get a full diary; the feed stops at 50 entries.
Free tier limits for Letterboxd
- Feed size: 88 to 116 KB for 100 items, far under the 1 MiB cap.
- Rate: 600 requests per minute per client IP, with no daily or monthly quota.
- Public data only: the free tier forwards no cookies or
Authorizationheader. - Errors: proxy failures carry an
X-Cors-Errorheader; Letterboxd 403 and 404 responses pass through unchanged.
Good questions.
Does Letterboxd have a public API?
No. The API at api.letterboxd.com is private, and access is by request to api@letterboxd.com. Letterboxd says it does not approve personal projects, data analysis, visualizations, recommendation tools or LLM use. Member RSS feeds and the data export are the open options.
How do I get a Letterboxd API key?
Email api@letterboxd.com and describe the project. If it is personal or one of the refused types, skip the wait and read the member feed at letterboxd.com/USERNAME/rss/ instead.
Does Letterboxd have an RSS feed?
Yes. Every member has one at letterboxd.com/USERNAME/rss/ with the latest 50 diary entries, reviews included, plus up to 50 lists. Each entry has the film title, year, rating, watched date, rewatch and like flags and the TMDB movie ID.
How do I export my Letterboxd data?
Open Settings, then the Data tab, then Export your data, or visit letterboxd.com/user/exportdata/ while signed in. You get a ZIP of CSV files with your diary, ratings, watched films, watchlist, reviews and lists.
Why does fetch() to letterboxd.com fail?
Letterboxd sends no Access-Control-Allow-Origin header, so the browser blocks the response; fetch it through https://proxy.cors.dev/ instead. Profile, diary and review pages are a separate problem: they return a 403 Cloudflare challenge even from a server, so use the RSS feed for diary data.
Can the Letterboxd RSS feed return more than 50 diary entries?
No. The feed has no paging, and ?page=2 returns the same items. For a full history the member exports their data, or your app stores entries as it polls the feed.
How do I get a film's average rating from Letterboxd?
Fetch the film page, such as letterboxd.com/film/parasite-2019/, through the proxy and read aggregateRating from the JSON-LD script. Parasite showed 4.51 out of 5 from 5,947,740 ratings in October 2026.