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.
{
"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.
Related¶
- Journey access — which journeys a reader may open.
- Journey import — the Journey Builder export contract.
- Editor guide — how to build a journey in the CMS.
- Data model — the journey tables and what they link to.