Skip to content

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 from GET /api/themes/, caches it, and re-fetches only when config.themes_hash changes (see App boot and Themes below). Every theme_id resolves 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
}

kindpassage · 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.

{ "api_version": "1.0", "themes_hash": "a1b2c3d4", "current_theme": 4 }

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}/

kindpassage · 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. ChapterGET /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

kindpassage · 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

  1. Pool sizes are 10 for both hero and Further Reading — can be increased if needed.
  2. body is Markdown, restricted to bold / italic / link / list.
  3. related ships as an empty array (key always present) until the rail is built.
  4. "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?