Themes API guide¶
Status: Live
Implemented and deployed to dev (CSL-93, CSL-137).
Orientation for the theme endpoints. Swagger (/api/docs/, /api/redoc/) and the Linear ticket
hold the exact request and response schemas. This guide covers what each endpoint is and how it
behaves, not every field.
See Themes for the concept, and API Conventions for auth, pagination, and error handling.
Endpoints¶
GET /api/themes/returns the full portal set for the Themes tab, in default order (by name).GET /api/themes/{slug}/returns one portal by slug, with404on an unknown slug.GET /api/themes/{slug}/pieces/returns the theme's "explore in depth" pieces carousel.GET /api/themes/{slug}/journeys/returns the theme's journeys carousel.GET /api/themes/{slug}/passages/returns the theme's passage pool, which serves both the opening and closing slots.GET /api/themes/{slug}/explore/returns the theme's whole collection, paged for scrolling.GET /api/themes/featured/returns the single featured theme, the source for the Home "Theme of the Week".
All are public, app-key-gated (X-API-Key), user-agnostic, and cacheable (Cache-Control: public).
List and detail items carry id, slug, name, a framing_line (a short one-line theme
description), and a design object.
The design block¶
The server assembles design at read time from a color object, the portal_icon and background
image URLs, and a spread of the theme's metadata catch-all. Key behaviour to know:
coloris the theme's palette:accent,arch, andbackground(the three core hexes), plus the accent'sopacity_50,opacity_20, andopacity_0tints (8-digit hex). Palettes are shared across themes. For example, Friendship and Courage are both the "orange" palette. A theme that is not part of the design library serves"color": {}, an empty object, nevernulland never a partial palette. The client should fall back to its own default in that case.portal_icon(the theme's arch glyph, one per theme) andbackground(the full-screen wash, shared per palette) are served as their original PNG URLs, with no Wagtail renditions, because the Figma exports are pre-optimised. Either isnullwhen unset.portal_animationis the theme's animated portal, a Rive.rivfile URL, one per theme. The background.rivis common to all themes and bundled in the app, so this block does not serve it.portal_animationis currently a shared design sample for every theme, until design delivers the per-theme files. It isnullwhen unset.- New design tokens appear without a schema change or client release. Assets that are not yet
final (lottie, colour variants) ride in
metadata, and the server spreads them intodesignas editors add them. Treat unrecognised keys as optional. - Concrete fields win. A stray
color,background, orportal_iconkey inmetadatacannot shadow the real one.
Pieces carousel: GET /api/themes/{slug}/pieces/¶
The theme feed's "explore in depth" carousel. The response is {"items": [ ...content cards ]}, up
to 10 of the theme's long-form pieces. It returns 404 when the slug is not a theme, and empty
items when the theme has none.
How it behaves:
- Auto-populated, not curated. Pieces are drawn from published content, not an editorial
per-theme list. Editors control the carousel by publishing and theme-tagging. Membership is by
primary_theme, the single editor's-pick theme and the shared theme-feed contract, not the multi-select tag set. - The pool is Lewis's essays and letters (Writings) and editorial Articles. It excludes Passages (the theme's passages are a separate endpoint), Books, and chapters.
- Order is newest-published first, and deterministic.
- Each item is the shared content card:
kind(the type chip),title,supporting_line(the subtitle),image(the artwork),read_time_minutes, andtheme_id. A tap opens that piece's reading screen.
Journeys carousel: GET /api/themes/{slug}/journeys/¶
The theme feed's journeys carousel. The response is {"items": [ ...journey cards ]}, up to 10 of
the theme's journeys. It returns 404 when the slug is not a theme, and empty items when the
theme has none.
How it behaves:
- Auto-populated, not curated. Journeys are drawn from theme tagging, not an editorial
per-theme list. Editors control the carousel by tagging a journey and publishing it. Membership
is by
primary_theme, the same contract the pieces carousel uses, not the multi-select tag set. A journey tagged with a theme that is not its primary theme does not appear in that theme's feed. - Order is newest-published first, with undated journeys last, and deterministic.
- Only published journeys appear. A draft is skipped.
chapters_countis what a reader can open, which for a live journey is every chapter it was authored with: the CMS refuses to unpublish a piece a live journey is built on. A journey with nothing readable left would be dropped rather than advertised. See Journeys.- Each item is the journey card, the same shape the Home Featured Journeys carousel serves:
id,slug,name,editorial_framing(the pitch),colour,emblem(the artwork as a set of image sizes), andchapters_count. There is notheme_id, because the card is coloured by the journey's owncolour. A tap opens that journey. - No reader state. No progress, no completed chapters, no collected emblem, so the response stays cacheable for everyone.
The difference from Home's carousel is only where the list comes from. Home is an editor's hand-picked, drag-ordered list of up to 5. This is every journey whose primary theme is this one, newest first, up to 10. A journey that merely carries the theme in its multi-select tags is not here.
Passages: GET /api/themes/{slug}/passages/¶
The theme portal's passages. The response is {"items": [ ...passage cards ]}, up to 10 of the
theme's passages. It returns 404 when the slug is not a theme, and empty items when the theme has
none. This is the Home hero mechanics (the daily-quote rotation) scoped to one theme.
One pool, two slots¶
The portal shows a passage at the top (the opening) and, optionally, one at the bottom (the closing). Both come from this single response. Fetch it once per portal visit and draw two different items:
items = GET /api/themes/{slug}/passages/
opening = items[i] i random in 0 ... n-1
closing = items[j] j random in 0 ... n-1, j != i
- Fewer than 2 items means no closing passage. A theme with a single passage has an opening and nothing to close with, so hide the slot.
- The client draws both from the pool it already holds, so the closing passage can never repeat the opening. There is no second request and no exclusion for the server to apply.
?exclude= is a cursor, not a filter
?exclude=1,2,3 advances the window past those ids and wraps, so on a small pool it hands them
straight back. A theme with three passages, excluding the first, returns all three with the
excluded one last. Use it to rotate what a returning visitor sees across sessions. Do not use it
to keep the closing passage away from the opening. Draw both from one pool instead.
How it behaves:
- Membership is by
primary_theme, the single editor's-pick theme and the shared theme-feed contract. This is the same basis as the pieces carousel, not the multi-select tag set that the reading-screen rails use. - A pool, not one item. The response is a rotating window of up to 10 passages in the fixed published order. The same input returns the same output, so it caches. The per-session pick is the client's precisely so this response stays identical for everyone.
- Each item is the shared passage card (
id,slug,quote,source,audio,theme_id), the same shape the passage list and Home hero return. A tap opens that passage's reading screen. GET /api/themes/{slug}/opening-passages/still works and returns the same response. It is the name this pool shipped under when it served the opening slot alone. Prefer/passages/.
Keep exploring: GET /api/themes/{slug}/explore/¶
The vertical list that closes the theme feed. The response is
{"results": [ ...content cards ], "next": "<url>"}. It returns 404 when the slug is not a theme,
and empty results when the theme has none.
How it behaves:
- Same pool as the pieces carousel, complete instead of capped at 10. Membership is by
primary_theme, and the order is newest-published first, so the carousel is the top of this list. The client carries on from where the carousel stopped rather than showing those pieces again. - Passages are not here. They have their own portal slots, and a passage card leads with a quote rather than a thumbnail.
- Paged by cursor. Request
nextas it comes, without parsing it.nextisnullon the last page, including when the collection ends exactly on a page boundary. Use?limit=to set the page size, default 30, maximum 100.nextcarries yourlimitforward, so the page size holds for the whole scroll. - A malformed cursor is refused with
400rather than quietly restarting the list, so a client bug surfaces instead of looking like a short list. The cursor is a position, not a credential: it is not signed, so a caller who hand-builds a well-formed one gets the page at that position. Nothing here is private, so that costs the caller a page they could have reached by paging anyway. - No total count. A scrolling list does not display one, and counting the collection on every page would cost what the cursor saves.
- Ties are resolved, not left to chance. Our importers stamp a whole batch with one
first_published_at, so equal timestamps are common. On an equal timestamp, letters and essays come before articles, then higher ids first. - Each item is the shared content card, the same shape the carousel returns.
Why a cursor and not ?offset=:
OFFSET nmakes the database count past n rows, so each page costs more than the last. A cursor names a position, so page 50 costs what page 1 costs.- An offset window moves when a piece is published mid-scroll, which repeats or skips a row. A cursor is unaffected by anything ahead of the reader.
Change detection: themes_hash¶
Themes change rarely, so the app fetches GET /api/themes/ once and refetches only when the set
changes. GET /api/config/ carries a themes_hash, an opaque fingerprint of the served set that is
cached server-side and recomputed only when a Theme changes. Compare it to the last value held. If
it differs, refetch and replace the cache.
GET /api/themes/featured/ returns the featured theme as an id plus a slot label: theme_id
(resolve it against the cached set) and header (the editor's slot label, for example "Theme of the
Week"). It never returns an embedded theme object. Both are null when nothing is featured, and the
endpoint never returns 404. Featuring has no effect on GET /api/themes/,
GET /api/themes/{slug}/, or themes_hash.
Notes¶
slugauto-derives from the name once, then stays stable across renames. It is the deep-link and analytics identifier, so cachedtheme_idand slug references never break.- Ordering is by name. There is no featured-first ordering in the list or detail responses. The featured theme is its own endpoint above.
See Swagger for the precise field list, cache TTLs, and status codes.