Skip to content

Journeys

Status: Live

A reader can now find a journey and walk it end to end. The Journeys hub previews one journey from each of their three groups with the size of each group, the journeys list opens any one group in full, journey detail returns the course with its chapters, finishing a chapter is recorded on the server, and the emblem is collected at the end and kept. Two carousels also lead in — the Home Featured Journeys list, an editor's hand-picked set of up to 5, and a theme's journeys carousel, every journey whose primary theme is that one.

A Journey is a guided course through Lewis's work: an editor-curated, ordered set of pieces built around one central question, so a reader finds them in a considered order instead of searching.

The shape

A Journey is a list of chapters. A chapter is one piece — one Passage, Writing or Article — wrapped in the editorial framing that turns it into a step of the course.

Journey  "Becoming Your True Self"
  └── Chapter 1  "The Masks We Wear"     → one Passage
  └── Chapter 2  "Chasing the wrong thing" → one Passage
  └── …

A chapter is not a container of several readings. It holds one piece plus the words around it: the question that opens it, the lead-in pages, an optional pause, and the line shown when the reader reaches the end.

The reader moves through a chapter as a short arc — opening question, the editor's lead-in a page at a time, the piece itself, what it means, a pause, a reflection they can skip, then a glimpse of the journey's emblem. Almost all of the reading comes from the piece the chapter points at; the chapter supplies the connective tissue around it.

What belongs to what

Where it lives Why
The text itself, what it means, the reflection question, the themes the piece (Passage / Writing / Article) A piece is complete on its own and is read outside journeys too
The opening question, the lead-in pages, the pause line, the end-of-chapter line the chapter Written for this position in this journey
The central question, the pitch, the colour, the emblem, the intro and outro pages, the practice the journey Shared by every chapter, or asked once at the end

Because the reflection question and the themes belong to the piece, a piece read inside a journey and the same piece read on its own ask the reader the same thing and sit in the same worlds. A chapter is tagged once, on the piece — never twice.

A few rules worth knowing

  • A chapter must point at exactly one piece — a Passage, a Writing, or an Article, never two.
  • Order is set by the editor, by dragging chapters into place. Chapter numbers are worked out from that order when the app asks for them; they are never typed in.
  • Everything must be published before the journey can be. Publishing a journey with a chapter whose piece is still a draft is refused, and the CMS names the chapters holding it up.
  • A piece in a live journey cannot be unpublished. The CMS refuses it and tells you to unpublish the journey first, or point that chapter at another piece. A live journey therefore never loses a chapter or renumbers the rest, which matters because a reader's progress is saved against particular chapters and renumbering would strand it.

Endpoint map

What Endpoint Guide
The Journeys hub: one journey per group GET /api/journeys/hub/ below
One group in full, paginated GET /api/journeys/?status= below
Journey detail and progress GET /api/journeys/{id}/ below
Begin a journey POST /api/account/journeys/{id}/start/ below
Record a finished chapter POST /api/account/journeys/{id}/chapters/{chapter_id}/complete/ below
Collect a finished journey's emblem POST /api/account/journeys/{id}/claim-emblem/ below
The reader's collected emblems GET /api/account/journeys/emblems/ below
Home Featured Journeys carousel GET /api/journeys/featured/ Home API
A theme's journeys carousel GET /api/themes/{slug}/journeys/ Themes API

Journey detail is public with an optional sign-in. Everything under /api/account/ needs a bearer token and is never cached.

The hub: GET /api/journeys/hub/

The Journeys landing page. One journey from each of the reader's three groups, plus how many each group really holds:

{
  "in_progress": [ { …card, "chapters_count": 3, "completed_chapters_count": 1,
                           "current_chapter": {"id": 91, "number": 2},
                           "last_active_at": "2026-08-27T09:14:00Z" } ],
  "not_started": [ { …card, "chapters_count": 5, "completed_chapters_count": null } ],
  "completed":   [ { …card, "chapters_count": 4, "completed_chapters_count": null,
                           "completed_at": "…", "emblem_claimed": true } ],
  "totals": {"in_progress": 4, "not_started": 12, "completed": 1}
}

…card is the same journey card the carousels serve: id, slug, name, description, primary_theme_id, emblem, chapters_count. Every group also carries completed_chapters_count, so one shape reads everywhere; it is null where a progress bar would say nothing, which is every not_started and completed card, and chapters_count is the denominator. Beyond that each group adds only what its own row renders.

Each group holds at most one card. This is the landing screen: a section shows its first journey and a link to the rest. To open a group in full, call GET /api/journeys/?status=<group>. It applies the same filter and the same ordering, so the card here is the first item there.

totals counts the whole group, not this response. It is what a "see all" link should key off: without it a group of one would get a link leading to a list holding the card already on screen. The totals count exactly what the list endpoint would serve, so a total can never promise a page that comes back empty.

A journey is in exactly one group, and it is the same word its detail returns. The group and state.status on journey detail agree by construction. The list derives status in SQL so it can be filtered and paged; journey detail derives it in Python. A test asserts the two agree across every combination of reader state, because two implementations of one rule is how they drift.

Sign-in is optional. With a token the groups describe that reader. Without one, not_started holds the whole catalogue and the other two are empty, with matching totals. Nothing is invented, and a guest can browse and open any journey. Starting one needs the auth gate. An invalid or expired token is a 401, not a guest read: the two would otherwise look identical, and the reader would be shown a hub claiming they had started nothing.

All four keys are always present. An empty group is [] and an empty catalogue is three empty arrays with zero totals and a 200, so both are distinguishable from an error.

Not cacheable — private, no-store, for the same reason as journey detail.

One group in full: GET /api/journeys/?status=

What a hub "see all" link opens: every journey in one group, paginated.

GET /api/journeys/?status=in_progress&limit=30
{
  "count": 12,
  "next": "…?status=in_progress&limit=30&offset=30",
  "previous": null,
  "results": [ { …card } ]
}

status is required, and must be in_progress, not_started or completed. Anything else, including omitting it, is a 400. There is no mixed list: the three groups are ordered by three different keys, so a combined response would have no honest order to serve.

The cards are the hub's cards. Which extras one carries depends on the group asked for: in_progress adds current_chapter, last_active_at and a real completed_chapters_count; completed adds completed_at and emblem_claimed; not_started adds neither. One response only ever holds one group, so a client parses one shape per call.

Paged by limit and offset, the house convention. count is the size of the whole group, next is null on the last page including when the group ends exactly on a page boundary, and the page size defaults to 30 and is capped at 100. An offset past the end is an empty page with a 200, not an error.

Paging is a transport detail, not a screen. The reader scrolls one continuous list; the client fetches the next page in the background as they approach the end of what it holds. There are no page controls, no page numbers and no "load more" button in the design. So count is for deciding whether more exists, not for drawing "page 2 of 7".

in_progress reorders as the reader reads

That group is ordered by when the reader was last active, so finishing a chapter mid-scroll moves that journey to the front and a row can shift between pages. Refetch from the start after recording a completion rather than continuing to page through a list that has moved.

Ordering is server-side. Do not re-sort.

Group Order
in_progress When the reader last did something, newest first
not_started Newest published first, undated last — the same rule a theme's journeys carousel uses
completed Most recently finished first

Each falls back to the id, so a tie is stable across calls instead of left to the database. "Last did something" means their newest chapter completion in that journey, or when they started it if they have not finished one yet. That is derived from the completions rather than stored, so a mid-journey completion cannot leave it stale.

Eligible means published, with at least one chapter a reader can open. A journey with nothing openable is absent and uncounted rather than shown as a card that goes nowhere, the same rule the carousels apply. So a missing card is an eligibility result, not an ordering or synchronisation error. For a journey edited only through the CMS this never bites: it refuses to unpublish a piece a live journey is built on.

A retired journey leaves every group, including a reader's completed, because every card here opens journey detail and that is a 404 for a draft. Nothing earned is lost: the collected emblems list is deliberately not filtered to published, and is the surface that remembers it.

Sign-in is optional here too. Without a token every journey is not_started, so ?status=in_progress is an empty 200 rather than a 401.

&free=true narrows to the journeys an editor marked always-free. Editorial, not personal — it lists what the editors opened to everyone, not what this reader may open. Those are different lists: a reader with their free journey intact may open the whole catalogue, and labelling all of it "free" would say nothing. Anything other than true, including false, means no filter.

A reader's own free journey is one they have started, so it never appears under ?status=not_started&free=true.

Every card carries access, resolved for this reader — see access on journey detail, which is the same shape.

Not cacheable — private, no-store.

Journey detail: GET /api/journeys/{id}/

Everything the Journey detail screen needs in one call: the journey, every chapter in the editor's order, the editor's onward picks, and where the caller stands.

404 when the id is not a published journey. A draft is a 404 rather than a 403 — an unpublished journey is not one the reader was refused, it is one that does not exist yet.

Authentication is optional, and changes only state

Send a bearer token and state describes that reader. Send none and it is the not-started shape. Everything else in the response is identical either way, so a signed-out reader sees the whole journey and is asked to sign in only when they act on it.

state is never null. A guest and a signed-in reader who has not begun get the same object, so the client renders one screen instead of branching.

An invalid or expired token is a 401, not a guest read. Quietly downgrading a bad credential would hide an expired session behind a screen claiming the reader had done nothing.

This endpoint is not cacheable

Most content reads in this API are user-agnostic and carry a cache window with an ETag. This one carries private, no-store. It also needs a content-access token, because it carries the chapters' piece audio (see Content access).

That is deliberate. On this screen the reader's state is the screen: the emblem's blur, every chapter's lock, and whether the button says start, continue or replay all come from it. Splitting it into a public journey read plus a private state read would give the client two round trips it has to wait for both of before it can draw anything. A stale chapter lock or a stale claim would misdraw the screen, and the payload is small, so it refetches.

Chapters

Every chapter the editor authored, in their order, numbered from 1. The number is computed at read time rather than read off a stored column, because that column is nullable and can tie.

Chapters are never filtered out of the list. Every chapter that exists on the journey is returned, whatever state its piece is in. That matters because chapter completion is stored against a particular chapter, so a list that quietly dropped one and renumbered the rest would strand a reader mid-course. The CMS backs this up by refusing to unpublish a piece a live journey is built on — though an editor removing a chapter outright is still allowed, and does delete progress recorded against it.

Each chapter carries the editorial wrapper written for that position in that journey — the opening question, the lead-in pages (Markdown, one string per page, in order), its own description, an optional pause line, and the line shown as the emblem comes into focus. reflection_prompt is the piece's own, read through, so a piece asks the same question inside a journey as anywhere else. practice is on the journey, not the chapter — one practice for the whole course.

Intro and outro pages

The journey has its own opening and closing pages, apart from any chapter's lead-in:

"intro_pages": ["Before we begin…", "Read slowly."],
"outro_pages": ["You have walked the whole of it."]

Each is a list of Markdown strings, one per page, in the editor's order, and either list can be empty. Show the intro pages one at a time before chapter 1. Show the outro pages one at a time after the final chapter's emblem reveal, before the reader collects the emblem.

Neither list records progress. The journey is already complete when the reader reaches the outro, because completion happens at the final chapter's emblem reveal. When both lists are empty, the journey reads exactly as it did before these fields existed.

The piece itself is fetched separately

A chapter's content is a reference, not a payload: a type of passage, writing or article plus the id and slug.

type picks which existing endpoint to call — /api/passages/{id}/, /api/writings/{id}/, /api/articles/{id}/. Each piece keeps its own endpoint and payload rather than getting a journey-flavoured copy, so the three content types keep their real differences: a Passage has an interpretation step, a Writing and an Article do not. A chapter's passage belongs to that chapter and appears nowhere else in the app, but its detail endpoint serves it like any other passage. See Journey passages. A writing or an article can sit in several chapters and elsewhere in the app.

Reader state

Field What it says
status not_started, in_progress, or completed
current_chapter_id The chapter the button opens: the first with no completion recorded. null when every chapter is done
completed_chapter_ids Finished chapters, in reading order
completed_chapters_count / total_chapters How far along, out of how many
emblem_claimed / emblem_claimed_at Whether the emblem has been collected, and when

May this reader open it: access

Beside state, never inside it. state is how far the reader has come; access is whether they may be here at all.

Field What it says
access.state open or locked
access.reason granted, always_free, subscription, first_read, needs_subscription, or gate_off

A locked journey comes back whole but emptied. Detail is a 200 preview: the name, description, emblem and every chapter's title and blurb are there; the practice, the outro_pages, the chapter prose, the reflection prompts and every other audio track are blank ("", [], null). Keys never disappear, so the response decodes the same either way.

The intro pages are the exception. A locked journey still returns its intro_pages and their audio in full, because the intro is the journey's invitation. Show it to a reader before you ask them to subscribe.

Offer the subscription on reason == "needs_subscription", not on state == "locked". A reader who has not used their free journey gets open / first_read for everything — starting any one of them claims it, which is an invitation rather than a purchase. Every guest is in that state, so a guest browsing must never meet a subscription offer.

gate_off means gating is suspended in this environment, so nothing is locked for anybody. The paywall is on by default; an environment can switch it off for a demo or a QA pass.

The full rules, including what happens when an editor changes their mind, are in the access contract.

Each chapter also carries its own state: locked, available, or completed. Progression gates order and nothing else. There is no pace rule, no streak and no day count.

Counts, not a reveal fraction. The emblem is one image that the app blurs and unblurs as chapters complete. The API serves the two numbers and lets the client decide how far that is; a fraction computed here would freeze one rendering rule into the contract.

The first chapter is always open. There is no separate "start" call. Completing chapter one is what starts the journey, so the button always has somewhere to go.

A finished journey stays finished. It remains replayable and re-reading never resets progress or offers a second claim.

completed does not always mean there is nothing to open. An editor can add a chapter to a live journey, including one a reader already finished. The journey stays completed — that is never revoked — while the counts read 2 of 3 and current_chapter_id points at the new chapter. All three are true; deciding whether to show the replay screen or the new chapter is the client's call.

related_journeys is always returned. The design shows it on the finished state, but that is a rendering rule, so the API sends it either way and the client gates it on state.status. Keeping the gate out of the API also keeps the journey body identical for every reader.

Reads never write. A reader who opens a journey and backs out has not started it, and no progress row is created.

Audio

Journey detail carries the whole listen-through inline, so there is nothing else to fetch before pressing play. Each chapter has an audio object with the same keys as its framing fields (opening_question, intro_pages as a list parallel to the text pages, pause_line, reveal_line) plus the piece's own tracks under content. The journey's own audio carries intro_pages and outro_pages, each a list parallel to its text list, and practice. Within a chapter, play order is the field order: opening question, lead-in pages, the piece, pause line, reveal line. The journey's intro pages play before chapter 1. The practice and the outro pages come after the final chapter.

On a locked journey, audio.intro_pages still carries playable tracks. audio.outro_pages is [], matching the empty outro_pages, and audio.practice is null.

The piece's tracks are the same ones its own detail endpoint returns, so a reader who opens the passage mid-journey hears the same files. Audio is per reader once the planned "first journey free, then subscription" gate lands: a track the reader may not hear is null. Not flagged locked — that word is a chapter's progression state, and entitlement answers in access. This payload is already private, no-store, so nothing about caching changes.

The track shape and the placeholder-audio warning are in Audio.

Beginning a journey: POST /api/account/journeys/{id}/start/

What the Start Day / Start Chapter button calls, and the only thing that claims a reader's free journey.

201 Created (200 on a repeat):
{
  "state": {
    "status": "in_progress",
    "current_chapter_id": 12,
    "completed_chapter_ids": [],
    "completed_chapters_count": 0,
    "total_chapters": 5,
    "emblem_claimed": false,
    "emblem_claimed_at": null
  }
}

The response is the same state block journey detail returns, so the app opens the current chapter without a second call.

Whichever journey a reader starts first is theirs permanently. Finishing it, replaying it years later, an editor's change of mind and the end of a subscription all leave it alone. Everything after it needs a subscription, unless an editor marked that journey always-free.

Safe to call again. A second tap, a retry after a dropped connection, and the resume that follows sign-in all land where one tap would have: one free journey claimed, one start recorded. 201 the first time, 200 after.

Status When
201 Started, and recorded for the first time
200 Already started; nothing changed
402 This journey needs a subscription the reader does not have
401 No valid bearer token
404 Not a published journey

Finishing a chapter is gated the same way and returns the same 402, so skipping this call does not skip the gate. Collecting an emblem is not gated: a reader who finished a journey keeps what they earned even if their subscription has since ended.

402 rather than 403, which is already what a missing or wrong app key returns — a client reading only the status code must not confuse "your key is wrong" with "buy the subscription". The error envelope's error_type is SubscriptionRequired.

Finishing a chapter: POST /api/account/journeys/{id}/chapters/{chapter_id}/complete/

The authoritative progress event. Send it when the reader moves from Reflection into the emblem reveal — that transition is what completion means, not reaching the end of the text and not any dwell time.

The response is chapter_id — the id of the chapter just recorded, not the chapter itself — plus the reader's whole journey state afterwards, in the same shape journey detail returns. So the client replaces what it holds rather than patching it, and the two surfaces cannot drift.

Chapters are addressed by id, not by number. The 1-based number is derived at read time and an editor can change it by reordering; the id is what a completion is stored against. (The older FE contract sketch used chapters/{n}/ with the ordinal. That would reintroduce exactly the fragility the stored-progress design removes.)

Repeats are safe, in both directions. Re-sending the chapter the reader just finished writes nothing and returns the same state. Re-sending an earlier chapter they replayed does not rewind the journey or restamp a completion. A double tap and a retried request are the same single fact, held by a unique constraint rather than by the client behaving.

Order is gated, pace is not. A chapter further ahead than the reader has reached is a 409. There is no streak, no cooldown and no day count.

Finishing the last chapter completes the journey, in the same transaction, so state.status comes back as completed on that one call. It does not collect the emblem: that is a separate act, and a journey that is finished but uncollected is the state the completion screen renders.

Nothing else moves progress. Saving, skipping or failing to save a Reflection, opening related content, and acknowledging a Practice all leave it exactly where it was.

404 when the journey is not published, or when the chapter is not one of that journey's chapters — a chapter id borrowed from another journey is refused rather than recorded against the wrong course. 401 without a valid bearer token: progress always belongs to a named reader.

This is no longer the only thing that starts a journey. POST …/start/ now does, and is what the Start button calls. Completion still creates the progress record if it is somehow reached first, so a client that skips Start is not broken — but it will be refused for a journey the reader may not open, exactly as Start is.

The emblem: POST /api/account/journeys/{id}/claim-emblem/

Records that the signed-in reader collected this journey's emblem. 201 the first time, 200 on any repeat.

Why it sits under /api/account/. Everything user-specific does. Journey detail is the one exception, because there the reader's state is the substance of the screen rather than an overlay on it; a write has no such argument, so it goes where the convention puts it.

Repeats are safe. The claim is a timestamp on the reader's one progress row for that journey, so a second claim has nowhere to be stored. A double tap or a retried request returns the original claimed_at unchanged rather than moving a collect the reader already made.

You have to finish first. 409 if the journey is not complete for that reader, including when they never started it. The database enforces the same rule, so the check in the endpoint is the friendly message rather than the guarantee.

Failure is safe to retry. A refused or failed request leaves the journey finished and uncollected. Nothing is half-written.

404 if the id is not a published journey. 401 without a valid bearer token: a collected emblem always belongs to a named reader, and the app key alone will not do.

The collection: GET /api/account/journeys/emblems/

Every emblem the reader has collected, most recently collected first, with the journey it came from, the picture, its title and line, and when it was taken. Empty items when they have collected none.

Retiring a journey does not empty a collection. This list is deliberately not filtered to published journeys. Unpublishing takes a journey out of the carousels and makes its detail a 404, but a reader keeps what they earned, and this is the only surface that still shows it.