Skip to content

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, with 404 on 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:

  • color is the theme's palette: accent, arch, and background (the three core hexes), plus the accent's opacity_50, opacity_20, and opacity_0 tints (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, never null and 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) and background (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 is null when unset.
  • portal_animation is the theme's animated portal, a Rive .riv file URL, one per theme. The background .riv is common to all themes and bundled in the app, so this block does not serve it. portal_animation is currently a shared design sample for every theme, until design delivers the per-theme files. It is null when 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 into design as editors add them. Treat unrecognised keys as optional.
  • Concrete fields win. A stray color, background, or portal_icon key in metadata cannot shadow the real one.

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, and theme_id. A tap opens that piece's reading screen.

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_count is 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), and chapters_count. There is no theme_id, because the card is coloured by the journey's own colour. 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 next as it comes, without parsing it. next is null on the last page, including when the collection ends exactly on a page boundary. Use ?limit= to set the page size, default 30, maximum 100. next carries your limit forward, so the page size holds for the whole scroll.
  • A malformed cursor is refused with 400 rather 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 n makes 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

  • slug auto-derives from the name once, then stays stable across renames. It is the deep-link and analytics identifier, so cached theme_id and 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.