Skip to content

Journeys API guide

Status: Live

Journey detail with reader progress (CSL-180) and collecting a journey's emblem (CSL-184) are implemented. The one write still missing is recording a completed chapter (CSL-183), which is what moves a reader through a journey, so until it lands a reader's state only changes if rows are seeded directly.

Orientation for the Journey endpoints. Swagger (/api/docs/, /api/redoc/) and the Linear tickets hold the exact request and response schemas. This guide covers what each endpoint is and how it behaves, not every field.

See Journeys for the concept, and API Conventions for auth, errors, and caching.

Endpoint map

What Endpoint Guide
Journey detail and progress GET /api/journeys/{id}/ 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.

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

Every other content read in this API is user-agnostic and carries Cache-Control: public, max-age=<n> with an ETag. This one carries private, no-store.

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.

The list never shrinks. A live journey cannot lose a chapter: the CMS refuses to unpublish a piece a live journey is built on. That rule exists for this endpoint's sake, because chapter completion is stored against a particular chapter and a journey that quietly dropped one and renumbered the rest would strand a reader mid-course.

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), an optional pause line, an optional practice, 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.

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}/. A passage read inside a journey is the same passage read anywhere else, so it keeps its own endpoint and its own payload rather than getting a journey-flavoured copy. That also means the three content types keep their real differences: a Passage has an interpretation step, a Writing and an Article do not.

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

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.

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.