Mobile API — Full App Flow (Mock Contract)¶
Mock contract — not implemented
Proposed contract for FE confirmation, not built. Only the Passages and Themes endpoints exist today; everything else here is a design target.
Journey payloads predate the flattened model (2026-08-13)
The Journey sections below were written against the Draft 1 spine — a chapter grouping ~4 pieces. The chapter-menu payload and the keystone reveal have been removed rather than rewritten; what remains (§§4–5, 7) is broadly right in shape but has not been re-verified field by field.
The flattened reader contract is owned by CSL-246, not by this document — the models and CMS shipped first, deliberately. For what changed, see journeys-and-home.md §3.
Field names match the backend design (design doc ·
schema). Please review each payload against the screens and flag
anything missing before we build. Screens referenced from the INT Figma (Journeys flow
12490-5375, Home flow 12155-21902).
Conventions (per API overview): JSON over HTTPS under /api/; every request
carries the shared X-API-Key header; authenticated calls additionally send Authorization:
Bearer <firebase-id-token>; errors use the standard error envelope.
Public reads are user-agnostic. Every content/curation GET here is identical for every
caller and view-cacheable — it carries no per-user fields (bookmarked, progress,
ongoing, per-chapter state, per-piece completed, sealed reveals). All user state is served by
the authenticated /api/account/… endpoints and overlaid by the FE client-side.
Recurring shapes:
-
theme_id— themed payloads carry only the id of the resolved primary theme, never the theme object. Themes are static reference data: the FE fetches the full set once fromGET /api/themes/, caches it, and re-fetches only whenconfig.themes_hashchanges (see App boot and Themes below). Everytheme_idresolves against that cached set; the full theme shape (name,framing_line,design) is defined by the Themes endpoint. -
content card — every list/carousel item is a card:
{
"kind": "letter",
"id": 118,
"slug": "to-arthur-greeves",
"title": "To Arthur Greeves",
"supporting_line": "from Great Bookham, Surrey",
"image": {
"small": { "url": "https://cdn.example.com/img/letter-118.width-360.format-webp.webp", "width": 360, "height": 240 },
"medium": { "url": "…width-720.format-webp.webp", "width": 720, "height": 480 },
"large": { "url": "…width-1080.format-webp.webp", "width": 1080, "height": 720 },
"xlarge": { "url": "…width-1440.format-webp.webp", "width": 1440, "height": 960 },
"xxlarge": { "url": "…width-2048.format-webp.webp", "width": 2048, "height": 1365 }
},
"read_time_minutes": 4,
"theme_id": 4
}
kind ∈ passage · letter · essay · chapter · article · journey · book, present on
mixed-kind payloads and on nested polymorphic source objects; single-kind endpoints
(e.g. the passage list + hero) omit the top-level kind since it would be constant. Book cards appear
only as source objects and in featured_books (no reading surface, so no
read_time_minutes/body). Cards carry no bookmarked flag — the FE overlays bookmark state
from GET /api/account/bookmarks/. Every content object carries both id and slug —
endpoint paths use id; slug is the stable human-readable identifier (deep links, analytics,
imports).
1. App boot — GET /api/config/¶
First fetch. Carries the theme-cache version and the active theme id: themes_hash is an
opaque token over the current theme set — when it changes, the FE re-fetches GET /api/themes/
and refreshes its cache. current_theme is the Theme-of-the-Week id (the same value the
dedicated GET /api/theme-of-week/ returns — both come from one shared service). A user with no
personal pick falls back to current_theme.
Not the splash. The splash-screen portal/door animation is a separate, TBD visual and is not tied to the Themes feature. Themes are the in-app portals; the splash is its own asset.
2. Home — composed from independent endpoints¶
Home is not a single aggregate call, and there is no /home/ namespace. The section order is
fixed and known to the FE (not user-reorderable, per CSL-28); each section is its own
domain-scoped endpoint the app fetches independently — the hero first (above the fold, mandatory),
the rest lazily / in parallel as the user scrolls. This avoids waiting on the slowest section, and
lets each endpoint carry its own cache TTL (see Caching). All are public and
view-cacheable — no per-user fields.
Each returns just its own payload. An empty non-mandatory section returns { "items": [] } and
the FE hides it. The hero is the one exception: if GET /api/passages/todays-featured/ fails or is empty,
that is a page error, not a hidden section.
| Home section | Endpoint | Fill | Cap | Notes |
|---|---|---|---|---|
| Today's Quote (hero) | GET /api/passages/todays-featured/ |
random pool of Passages | 10 | mandatory; FE rotates per session device-side; pool size adjustable |
| Featured Journeys | GET /api/journeys/featured/ |
editor pins → Featured list → skip | 5 | order preserved, deduped |
| Theme of the Week | GET /api/theme-of-week/ |
one theme id + resolved framing_line |
1 | id mirrors config.current_theme; one shared service backs both |
| What's New | GET /api/whats-new/ |
pins first, then newest eligible | 10 | reverse-chronological auto-fill |
| Featured Books | GET /api/books/featured/ |
Featured Books list order | 10 | manual-only |
| Further Reading | GET /api/further-reading/ |
random essays + letters pool | 10 | FE rotates per session device-side; pool size adjustable |
// GET /api/passages/todays-featured/ — the hero. Returns a pool; FE picks/rotates one per session.
{ "items": [
{
"id": 501, "slug": "ourselves-out-of-the-way",
"quote": "The more we get what we now call 'ourselves' out of the way, the more truly ourselves we become.",
"source": { "kind": "book", "id": 31, "slug": "mere-christianity", "title": "Mere Christianity", "cover": "…" },
"audio": "https://cdn.example.com/audio/passage-501.mp3",
"theme_id": 4
}
// … up to 10
] }
// GET /api/journeys/featured/
{ "items": [
{
"kind": "journey", "id": 12, "slug": "the-long-road-through-grief", "name": "The Long Road Through Grief",
"editorial_framing": "A Journey Through Longings",
"icon": "https://cdn.example.com/journeys/grief-icon.svg",
"colour": "#2E2440", "chapters_count": 7,
"theme_id": 4
}
] }
// GET /api/theme-of-week/ (implemented as GET /api/themes/featured/) — theme id + slot header label
{ "theme_id": 4, "header": "Theme of the Week" }
// GET /api/books/featured/
{ "items": [
{
"kind": "book", "id": 31, "slug": "the-screwtape-letters", "title": "The Screwtape Letters",
"publication_year": 1942, "synopsis": "A clever and unsettling series of letters…", "cover": "…"
}
] }
// GET /api/whats-new/ and GET /api/further-reading/ → { "items": [ …content cards ] }
3. Reading — GET /api/content/{kind}/{id}/¶
kind ∈ passage · letter · essay · chapter · article. One shape, kind-discriminated fields.
Example — the Letter reading screen:
{
"kind": "letter", "id": 118, "slug": "to-arthur-greeves",
"title": "To Arthur Greeves",
"supporting_line": "from Great Bookham, Surrey",
"original_date": "1916-03-07",
"recipient": "Arthur Greeves",
"read_time_minutes": 4,
"body": "I have had a great literary experience this week… The book, to get to the point, is George MacDonald's *Faerie Romance*, **Phantastes**, which I picked up by hazard…",
"reflection_prompt": "What book has changed how you see the world?",
"audio": null,
"source": { "kind": "book", "id": 74, "slug": "collected-letters-vol-1", "title": "The Collected Letters, Vol. I", "cover": "…", "publication_year": 2000, "synopsis": "…" },
"theme_id": 3,
"related": []
}
Kind-specific: passage adds interpretation + quote-style body; chapter adds
order (position within the source Work); article adds author, subtitle, publication_date. related ("Follow the
thread" / "More on this topic") ships as an empty array at first — rail mechanics are a
separate, unresolved track. body is Markdown (restricted set: bold, italic, links, lists;
serialization approach per ADR-0006).
4. Journeys tab — GET /api/journeys/¶
Public, view-cacheable — the full journey grid, identical for everyone. No ongoing block and no
per-journey progress; those come from GET /api/account/journeys/ (§7) and the FE merges them in.
{
"journeys": [
{
"kind": "journey", "id": 12, "slug": "the-long-road-through-grief", "name": "The Long Road Through Grief",
"editorial_framing": "A Journey Through Longings",
"emblem": "…", "colour": "#2E2440",
"chapters_count": 7,
"theme_id": 4
}
],
"sort": "editorial"
}
?sort= param: editorial (default) — others TBD with design ("Sort by" control).
chapters_count stays here because the grid returns no chapter list to count;
total_read_time_minutes is dropped (removed from the product).
5. Journey landing — GET /api/journeys/{id}/¶
Public, view-cacheable. No progress and no per-chapter state/bookmarked — the FE overlays
those from GET /api/account/journeys/{id}/ (§7). chapters_count is dropped: the chapters
array is complete (un-paginated), so the FE counts it. related_journeys is always present but
populated only for a finished journey.
{
"id": 9, "slug": "joy-unlooked-for", "name": "Joy unlooked for",
"editorial_framing": "Lewis spent years chasing a feeling he could never quite catch, and came to think the chasing was the point.",
"emblem": "…", "colour": "…", "theme_id": 4,
"chapters": [
{ "number": 1, "id": 91, "title": "The stab of joy", "opening_question": "What was that ache, and why did you want it back?", "read_time_minutes": 18, "theme_id": 4 },
{ "number": 2, "id": 92, "title": "Chasing the wrong thing", "opening_question": "Can you hunt joy down on purpose?", "read_time_minutes": 12, "theme_id": 4 },
{ "number": 3, "id": 93, "title": "Northerness", "opening_question": "Why did one old myth undo him completely?", "read_time_minutes": 15, "theme_id": 4 }
],
"related_journeys": []
}
Progression is order-gated only, never pace-gated — the locked · available · completed state
per chapter is user data (§7). related_journeys populates only for a completed journey.
6. Chapter — GET /api/journeys/{id}/chapters/{n}/¶
Superseded — not re-specified here
This section described a chapter menu: a chapter holding ~4 ordered pieces, one flagged
is_primary, with a sealed closing_reflection_question and keystone. None of that exists.
A chapter is one piece, there is no menu screen, the reflection prompt comes from the
piece, and the keystone is now the journey's emblem.
The replacement payload is owned by CSL-246 — Journeys reader contract, not by this document. Rewriting it here would pre-empt that ticket, so the old shape is removed rather than replaced.
7. Your journey state & actions — /api/account/… (auth required)¶
Everything user-specific lives under the account namespace, so the public journey reads (§4–§5) stay cacheable. The FE overlays these onto those cached payloads.
Reads (Cache-Control: private):
| Call | Returns |
|---|---|
GET /api/account/journeys/ |
ongoing list — per journey: status, current_chapter, completed_chapters, remaining_read_time_minutes |
GET /api/account/journeys/{id}/ |
this journey's progress + per-chapter state (locked · available · completed) |
GET /api/account/journeys/{id}/chapters/{n}/ |
this user's completed flag for the chapter — see CSL-246 for the shape |
// GET /api/account/journeys/
{ "ongoing": [
{ "journey_id": 7, "status": "active", "current_chapter": 2, "completed_chapters": 1, "remaining_read_time_minutes": 123 }
] }
// GET /api/account/journeys/{id}/
{ "progress": { "status": "active", "current_chapter": 1, "completed_chapters": 0 },
"chapters": [ { "number": 1, "state": "available" }, { "number": 2, "state": "locked" }, { "number": 3, "state": "locked" } ] }
// GET /api/account/journeys/{id}/chapters/{n}/ — per-user chapter state (shape: CSL-246)
{ "completed": false }
Actions (Cache-Control: no-store):
| Call | Effect | Returns |
|---|---|---|
POST /api/account/journeys/{id}/start/ |
creates progress (active, chapter 1) |
the progress object |
POST /api/account/journeys/{id}/chapters/{n}/complete/ |
completes chapter, unlocks n+1; final chapter also flips the journey to completed |
see below |
POST /api/account/reflections/ |
saves a reflection | the stored reflection |
chapters/{n}/complete/ response — progress, and how far the emblem has cleared:
{
"chapter": { "number": 1, "state": "completed" },
"journey": { "status": "active", "current_chapter": 2, "completed_chapters": 1, "total_chapters": 3 }
}
The keystone that used to sit here is gone. The reward is the journey's single emblem, which
the client reveals from completed_chapters / total_chapters — the backend serves one image and
no per-stage art. Exact shape: CSL-246.
POST /api/account/reflections/ body — prompt text is snapshotted: the prompt belongs to the
piece and an editor may change it, so we store what the user actually saw:
{ "prompt_snapshot": "Name the thing you keep almost catching.", "response": "…user text…", "chapter_id": 91, "content": { "kind": "letter", "id": 118 } }
8. Bookmarks — /api/account/bookmarks/… (auth required)¶
| Call | Effect |
|---|---|
PUT /api/account/bookmarks/{kind}/{id}/ |
bookmark (idempotent) |
DELETE /api/account/bookmarks/{kind}/{id}/ |
remove (idempotent) |
GET /api/account/bookmarks/ |
all bookmarks, grouped |
kind ∈ passage · letter · essay · chapter · article · journey · journey_chapter. This is the
set the FE overlays as bookmarked on the public cards.
{
"content": [ { "…content card…": true } ],
"journeys": [ { "…journey card…": true } ],
"journey_chapters": [ { "journey_id": 9, "number": 1, "id": 91, "title": "The stab of joy" } ]
}
9. Themes — portals & active theme¶
Themes are the app's portals. Two ways in: the bottom-nav Themes tab browses every portal, and the Home Theme of the Week section features one. Selecting a portal sets the user's active theme (its colours/portal/wash) — a display choice, it does not change what content is shown.
Themes are static and cached client-side. The FE fetches GET /api/themes/ once, caches the
full set, and re-fetches only when config.themes_hash changes — every theme_id elsewhere
resolves against this cache.
The pick lives on the account. For a signed-in user it persists server-side on
preferred_theme (see Account) and syncs across devices. For a
guest there is no account, so the FE uses Theme-of-the-Week (from config.current_theme /
GET /api/theme-of-week/) or remembers a pick locally. Active-theme resolution is the same
everywhere: the user's pick → else Theme-of-the-Week.
GET /api/themes/ — all portals, for the Themes tab (public, cached; version = config.themes_hash)
GET /api/themes/{slug}/ — one portal (public)
// GET /api/themes/
{ "items": [
{ "id": 4, "slug": "friendship", "name": "Friendship", "framing_line": "Companionship on the long road",
"design": { "colour": "#1B3A5C", "background": "…", "portal_icon": "…", "home_background": "…svg", "lottie": "…json" } },
{ "id": 5, "slug": "grief", "name": "Grief", "framing_line": "…", "design": { "…": true } }
] }
Order follows the CMS Featured Themes list first (editor-sequenced), then the rest by name.
Each item is a full portal (name, framing, design) so the tab renders without a follow-up call.
Theme as a content world (CSL-64) is out of scope here. If tapping a portal should open a screen listing that theme's content (passages/writings/journeys in it), that's a theme-content endpoint tracked under CSL-64 — this pass treats a theme as a visual portal + skin only.
10. Account — preferred theme¶
Extends the existing account endpoints (from CSL-30, which already has GET/DELETE) with the
user's persisted theme pick. preferred_theme is the theme the user explicitly chose, or null
if they never have — the FE resolves the active theme as preferred_theme → else
Theme-of-the-Week, so null is normal, not an error.
GET /api/account/ [auth: bearer] — now includes preferred_theme
PATCH /api/account/ [auth: bearer] — set/clear the pick
// GET /api/account/
200 OK: {
"uid": "abc123", "email": "user@example.com", "name": "Jack Lewis", "joined_at": "2026-07-08T12:00:00Z",
"preferred_theme": 4
}
// PATCH /api/account/ — set by theme id; send null to clear (fall back to Theme-of-the-Week)
Request: { "preferred_theme": 5 }
200 OK: { "…account…": true, "preferred_theme": 5 }
Request: { "preferred_theme": null }
200 OK: { "…account…": true, "preferred_theme": null }
Guests have no account, so they never call this — their pick (if any) is device-local. Backing
field: User.preferred_theme FK → Theme, nullable, on_delete=SET_NULL.
Caching¶
The server drives caching entirely through response headers — the client's HTTP layer just
honors them, no bespoke client cache logic. Public reads carry Cache-Control: public,
max-age=<n> plus an ETag: within the window a client/CDN serves from cache (no round-trip);
after it, the client revalidates with If-None-Match and the origin returns 304 (no body) while
unchanged or a fresh 200 after a CMS edit. The window is server-configurable
(API_PUBLIC_CACHE_SECONDS, default 30s) and tuned per env — low in dev/QA for freshness, higher
in prod — so staleness is bounded and revalidation is cheap.
| Response | Header | Why |
|---|---|---|
Public reads: config, themes, passages/todays-featured, journeys (+ /featured/), books/featured, whats-new, further-reading, theme-of-week, content, journey landing/chapter |
Cache-Control: public, max-age=<n> + ETag |
user-agnostic → shared/CDN-cacheable; the ETag makes post-window revalidation cheap and bounds staleness to the (tunable) window |
/api/account/… reads (progress, bookmarks, account) |
Cache-Control: private, max-age=<short> |
user-specific; never shared |
POST / PUT / DELETE |
Cache-Control: no-store |
mutations |
Hero and Further Reading return pools; the FE rotates within the cached pool device-side, so per-session freshness costs no extra request and doesn't fight the cache. This is identical for guests and signed-in users — the server keeps no per-user "seen" set; anti-repetition and rotation are entirely client-side, which is also what keeps the endpoint publicly cacheable.
Notes¶
- Pool sizes are 10 for both hero and Further Reading — can be increased if needed.
bodyis Markdown, restricted to bold / italic / link / list.relatedships as an empty array (key always present) until the rail is built.- "Day N of M" is display copy for chapter position (
current_chapter/chapters_count) — there is no calendar-day tracking anywhere (the Journey spec forbids streaks/day-counts).
Open question for FE¶
- Anything on the screens not covered by these payloads?