API reference¶
How the mobile app talks to the CS Lewis backend. Every endpoint is served under /api/.
Each page below says what an endpoint is and how it behaves. Swagger holds the exact request and
response schemas, at /api/docs/ and /api/redoc/ in non-production environments. The rest of this
page is the conventions every endpoint follows.
| Page | What it covers |
|---|---|
| Themes | The portal set, the design block, the per-theme carousels. |
| Passages | The atom: today's featured hero and the reading detail. |
| Writings | Letters, essays, and chapters read whole. |
| Articles | Contributor editorial. |
| Journeys | The hub, the list, journey detail, progress, and emblems. |
| Journey access | Which journeys a reader may open, and why. |
| Journey import | The Journey Builder export contract. |
| Home | The six Home sections, one endpoint each. |
| Reflections | A reader's private written answers. |
| Bookmarks | Saving a piece, and the saved list. |
| Subscriptions | Premium, entitlements, and RevenueCat. |
| Legal and Support pages | The four public HTML pages the app opens in a webview. |
Audio playback and subscriptions cut across the pages above, so they are documented here in Audio and Subscriptions.
Editors have their own book, the CMS editor guide.
Environments¶
| Surface | Dev | Prod |
|---|---|---|
| Host | dev-cslewis.fueled.engineering |
Not deployed yet |
| API base | https://dev-cslewis.fueled.engineering/api/ | Not deployed yet |
| OpenAPI schema | https://dev-cslewis.fueled.engineering/api/schema/ | Disabled |
| Swagger UI | https://dev-cslewis.fueled.engineering/api/docs/ | Disabled |
| Redoc | https://dev-cslewis.fueled.engineering/api/redoc/ | Disabled |
| Wagtail CMS (non-API) | https://dev-cslewis.fueled.engineering/studio/ | Unguessable prefix |
| Django admin (non-API) | https://dev-cslewis.fueled.engineering/backstage/ | Unguessable prefix |
Notes:
- The schema, Swagger, and Redoc interactive reference is served only in non-production
environments (
API_DOCS_ENABLED), so it is disabled in prod. - Admin route prefixes are configurable per environment (
WAGTAIL_ADMIN_URLandDJANGO_ADMIN_URL). Prod uses unguessable values, which are not published here.
App key¶
Every request must include a shared app key in the X-API-Key header:
The mobile team receives the key out of band, and it differs per environment. A
missing or unknown key returns 403 with error_type: "InvalidAPIKey". This key
identifies the app, not a user. For user identity, see Authentication.
Authentication¶
User identity uses Firebase. The app signs in with the Firebase SDK (a passwordless email link) and sends the resulting ID token as a bearer credential on every request that needs a user, alongside the app key:
Content reads are guest-open: no account is needed. Licensed text also needs a
content-access token (see Content access), which is not tied
to the Firebase account. Endpoints that act on a user require the bearer token
and return 401 without one.
There is no registration endpoint. The backend provisions the local account
on the first request that carries a valid token ("created on first sight"). After
sign-in, call GET /api/account/ once. It returns the profile and guarantees
the account exists.
A 401 means the token was missing, invalid, or expired (errors[0].message
says which). Refresh the ID token with the Firebase SDK and retry. The backend
creates an account only from a token with a verified email. The email-link flow
always produces one.
Account endpoints¶
| Endpoint | What it does |
|---|---|
GET /api/account/ |
Profile of the signed-in user (uid, email, name, joined_at), plus entitlements (see Subscriptions). Provisions the account on first call. |
GET /api/account/entitlements/ |
The reader's entitlements. See Subscriptions. |
POST /api/account/entitlements/refresh/ |
Pulls current subscription state from the store now; throttled to 5/min. |
PATCH /api/account/ |
Updates the display name. Body {"name": "..."}; returns the same profile GET does. |
DELETE /api/account/ |
Permanent account deletion (App Store mandate). Optional JSON body {"reason": "..."}. Returns 204. |
name is the only editable part of a profile. email and uid belong to the Firebase
identity and joined_at is a fact about the account, so sending them to PATCH changes
nothing — they are ignored, not refused. A name that is missing, blank, whitespace-only, or
longer than 255 characters is a 400, and a rejected request never changes the name on the
account.
Firebase's displayName seeds the name when an account first appears and never overwrites
it afterwards, so a name a reader sets here is not undone by their next request.
That seeding is part of provisioning, so it happens on the first authenticated request of any
kind — including one that goes on to fail validation. An account whose name is still empty can
therefore come back from a rejected PATCH holding the Firebase display name. Nothing a reader
chose was overwritten; the blank was filled in by sign-in, not by the request. In practice the app
calls GET /api/account/ at sign-in, so an account is seeded long before anyone edits it.
Deletion removes the Firebase identity first (with the caller's own ID token),
then the local account and its data. Two outcomes other than 204 need handling:
401means Firebase wants a recent sign-in before a destructive action. Re-authenticate the user and retry.502means the identity provider could not be reached. Nothing was deleted, so the call is safe to retry.
Deletion also deletes the reader's RevenueCat customer. That runs after the local delete and never changes the response: if RevenueCat fails, the daily reconciliation job retries it. Deleting the customer does not cancel a store subscription, so the app should tell the reader to cancel in the App Store or Google Play first.
DELETE with a valid Firebase ID token does not need X-API-Key. The
account-deletion web page (/delete-account/) signs the reader in with a Firebase
email link and calls this endpoint from the browser, where no app key can be
kept. Every other method on /api/account/ still needs the key.
Dev login: testing without a Firebase token¶
Non-production environments provide a bypass, so QA, API tooling, and Swagger try-it-out never need a real Firebase token:
The response:
The endpoint creates the account if it does not exist, so you can test fresh-user
flows with a new email. Use the returned token exactly like a Firebase ID token
(Authorization: Bearer ...). Swagger's Authorize dialog accepts it too. The
endpoint is gated by DEV_AUTH_ENABLED and returns 404 in production.
Content access¶
Licensed C.S. Lewis text and audio need a short-lived content-access token as well as the app key. Guests and signed-in readers get one the same way, and it identifies neither (CSL-256, CSL-257, ADR-0018).
Get a token:
- No Firebase token is needed or read. The response contains no content.
- The lifetime is configured on the server (15 minutes by default). Read
expires_inorexpires_atand refresh shortly before it. Don't decode the token. - Keep it in memory only. Never log it or send it to analytics or error reporting.
Send it on every request, alongside the app key and (when signed in) the Firebase token:
These endpoints require it:
| Endpoint | Licensed content |
|---|---|
GET /api/passages/ |
results[].quote, results[].audio |
GET /api/passages/todays-featured/ |
items[].quote, items[].audio |
GET /api/passages/{id}/ |
quote, related_quotes[].quote, audio |
GET /api/themes/{slug}/passages/ (and /opening-passages/) |
items[].quote, items[].audio |
GET /api/writings/{id}/ |
body, audio |
GET /api/articles/{id}/ |
body, audio |
GET /api/journeys/{id}/ |
the chapters' piece audio |
Card endpoints (themes, Home rails, journey lists) carry no licensed text and don't need it.
Errors are 401 with the standard envelope and one of three error_type
values. On any of them, mint a new token and retry the request once. Don't
treat them as a Firebase 401, which means the session is gone.
error_type |
Meaning |
|---|---|
ContentTokenMissing |
No X-Content-Token header |
ContentTokenInvalid |
Malformed, tampered with, or signed with a retired key |
ContentTokenExpired |
Past expires_at |
If minting fails, or the retry fails again, stop and show the content error. Don't loop.
Rate limits (CSL-258). Minting is limited per network, and licensed reading
per token session (a burst and a sustained limit, shared across every licensed
endpoint) and per network. Normal reading stays well under them. Over a limit,
the response is 429 with error_type: "Throttled" and a Retry-After header
in seconds: wait that long, then retry once. A session that keeps hitting its
limit is refused for 15 minutes. The refusal is per session: a newly minted token
starts a new session, which is why minting is limited too. Until enforcement is
on, a request without a token counts only against the network limit. The limits
are configured per environment.
Rollout. The server refuses a missing token only while
CONTENT_TOKEN_ENFORCED is on. A token that is sent is always checked. The
flag stays off in an environment until the app sends tokens.
Subscriptions¶
Premium access is an entitlement the backend mirrors from the store through RevenueCat. It is
never a client-set flag. The account endpoints return the reader's entitlements, and
GET /api/account/ carries them inline so the app knows access state at sign-in.
Each entitlement has a computed status: active, cancelled (paid through, auto-renew off),
billing_issue (payment problem, still active), or expired. Read status, not the raw dates.
| Endpoint | What it does |
|---|---|
GET /api/account/entitlements/ |
The reader's entitlements. |
POST /api/account/entitlements/refresh/ |
Pulls current state from the store now, throttled to 5/min. Use after a purchase or restore, so access does not wait on webhook timing. |
Entitlement reads are private, no-store. See the
Subscriptions API guide for the full schema and the webhook, and the
Subscriptions concept for how a purchase becomes access.
Versioning¶
Select the API version with the Accept header. There is no version segment
in the URL:
1.0 is the default and currently the only supported version. An unsupported
version returns 406.
The server also reports an advisory version at GET /api/config/:
Read api_version on launch to decide whether to prompt an update. It is advisory
only, and the server does not check it per request.
Errors¶
Every error response uses one envelope:
{
"error_type": "ValidationError",
"errors": [
{ "field": "email", "message": "This field is required." },
{ "message": "A non-field error." }
]
}
error_typeis a stable, machine-readable code. Branch on it.errorsis always a list. Field errors carry afieldkey, and non-field errors omit it.messageis human-readable and safe to display.
Status codes follow HTTP conventions: 400 bad request or validation, 403
forbidden (including a bad app key), 404 not found, 406 unsupported version,
409 conflict, and 500 server error.
Pagination¶
List endpoints use limit-offset pagination, with a default page size of 30:
{
"count": 240,
"next": "...?limit=30&offset=90",
"previous": "...?limit=30&offset=30",
"results": []
}
Content cards¶
A list that can hold more than one type of piece returns the shared content card, and every
card on it carries a top-level kind. What's New, Further Reading and the bookmarks list all work
this way.
kind is a label, not a route: passage, letter, essay, chapter or article. One model
covers three of those, so a letter is type: writing, kind: letter.
A card carries kind alone. Only the payloads that exist to be routed from add type beside it:
a bookmark's content, a reflection's source, a journey chapter's content. From a card, map
letter, essay and chapter onto the writings endpoint, and the other two onto their own.
A list that holds one type does not repeat itself: the passages list, the hero and Featured Books
omit kind, because it would be the same on every row.
A nested source object carries its own kind, because a passage can come from a Book or from a
Writing. Its values are book, or the source writing's letter, essay or chapter. Read kind
against the object you found it on.
Rich text¶
Prose fields are Markdown, not HTML and not plain text: a passage quote, an
interpretation.body, a writing or article body, a book synopsis, a chapter's intro pages.
Render them with a Markdown renderer, or readers see the syntax instead of the formatting.
Only five marks are ever emitted, because the CMS editor allows only these:
| Mark | Markdown |
|---|---|
| bold | **text** |
| italic | *text* |
| link | [text](https://…) |
| bulleted list | * item on its own line |
| numbered list | 1. item on its own line |
No headings, tables, images, block quotes or raw HTML — except an article body, which also
carries images ().
A backslash escapes a character that is part of the text rather than a mark: 3 \* 4 is the
literal asterisk, snake\_case the literal underscore. A renderer drops the backslash; treating
the field as plain text shows it. Paragraphs are separated by a blank line.
Images¶
Where an endpoint says so below, the API serves a content image as a rendition set, one URL per standard width, rather than a single link to the original upload.
"image": {
"small": { "url": "https://.../chivalry.width-360.format-webp.webp", "width": 360, "height": 240 },
"medium": { "url": "https://.../chivalry.width-720.format-webp.webp", "width": 720, "height": 480 },
"large": { "url": "https://.../chivalry.width-1080.format-webp.webp", "width": 1080, "height": 720 },
"xlarge": { "url": "https://.../chivalry.width-1440.format-webp.webp", "width": 1440, "height": 960 },
"xxlarge": { "url": "https://.../chivalry.width-2048.format-webp.webp", "width": 2048, "height": 1365 },
"original": { "url": "https://.../original_images/chivalry.png", "width": 2400, "height": 1600 }
}
| Key | Nominal width |
|---|---|
small |
360px |
medium |
720px |
large |
1080px |
xlarge |
1440px |
xxlarge |
2048px |
original |
The upload's own size |
- Pick the width you need. The ladder does not depend on any one screen, so it stays stable as screens change.
- Every key is always present. Switching size never needs a second request.
- The field is
nullwhen the piece carries no image. It is never an empty object. widthandheightare the rendition's real dimensions. Use them to reserve layout space before the image decodes.- Renditions are never upscaled. Trust
width, not the key name: an 800px-wide upload reportswidth: 800underxlargeandxxlarge, not 1440 or 2048. - Images are WebP, except
original. Each rendition URL is content-addressed by its filter, so a client can cache it indefinitely. originalis the uploaded file, untouched. It keeps the upload's format and full size. Use it only where re-encoding would harm the image: a journeyemblem, which the app tints with the theme colour, needs exact black, white and transparency, and the lossy WebP sizes shift them.
Where this applies. Every content image in the API uses a rendition set: the image on a
content card (What's New, Further Reading, the Home sections, a theme's pieces carousel, and the
related rails), the Writing and Article detail image, and a Book's cover wherever a book card
appears (Featured Books, and a passage's or writing's source).
Theme assets are the one exception. design.portal_icon and design.background stay single
original URLs, because they are pre-optimised Figma exports, so re-encoding them would only lose
quality.
The backend produces renditions when an editor saves the piece, not when you first request it, so a published image is already resized by the time it reaches you. ADR-0009 settles the sizes and how they are generated.
Audio¶
A piece is narrated one field at a time, not as one file per piece. A passage has three narrations, one each for the quote, the interpretation, and the question. A writing or an article has one, for the whole body. A journey chapter has one for each of its framing lines, and the journey has one for its practice and one for each of its intro and outro pages.
Audio is delivered inline on the payload, in an audio object keyed by the CMS field each
track narrates. There is no separate audio endpoint and nothing to fetch before pressing play.
Passage cards carry the same audio object their detail payload does, so the Home quote card
plays the passage through without opening the reading screen.
// GET /api/passages/501/ (audio-related fields only)
{
"id": 501, "slug": "ourselves-out-of-the-way", "kind": "passage",
"quote": "The more we get what we now call 'ourselves' out of the way...",
"interpretation": { "heading": "…", "subtitle": "…", "body": "…" },
"reflection_prompt": "…",
"audio": {
"body": {
"id": "aud_8123",
"url": "https://media.example.com/audio/passage/501/body/2f1c….mp3",
"expires_at": null,
"mime_type": "audio/mpeg",
"duration_seconds": 5.17,
"byte_size": 82741,
"voice": "aQzi7JE7SdhWNPlXsMcH",
"alignment_url": "https://media.example.com/audio/passage/501/body/2f1c….alignment.json"
},
"interpretation": { "…": "…" },
"reflection_prompt": null
}
}
The audio object
- Keys are CMS field names, and the key set is fixed per payload type. A passage's
audioalways hasbody,interpretationandreflection_prompt; a writing's or an article's always hasbody. Note the one naming gap: a passage'saudio.bodynarrates the text the payload shows asquote, andaudio.interpretationnarratesinterpretation.body. Each value is a track ornullwhen the field is blank or not narrated yet. Show the headphones control when any track is non-null. - A passage card's
audiois its detail payload'saudio. Same keys, same tracks, same recordings, so a card plays the whole passage in place and the FE reads one shape on both surfaces. Read the tracks, never the presence of theaudioobject. - Order is the reading order you already render. Quote, then interpretation, then question. The player owns sequencing and pauses: audio plays straight through, and a passage stops before the question because the app takes the reader to the reflection screen, not because the payload says so. The per-field split exists so the player can sync scrolling and navigation to the field being read.
- Some pieces have no narration at all, by editorial choice. Every track is
null; hide the control.
A track
| Field | Meaning |
|---|---|
id |
Opaque, stable id of the recording; changes when an editor re-records the field. Key resume state on it. Today aud_<opaque> for studio narration, doc_<n> for legacy editor-attached uploads, null for a sample clip. Do not parse it. |
url |
Opaque. Do not parse it, and do not cache it past expires_at when that is set. |
expires_at |
ISO 8601, or null while URLs are unsigned. Signing arrives with journey gating, with a days-long expiry. |
mime_type |
audio/mpeg today. |
duration_seconds |
Track length, measured at generation, so progress can be drawn before playback. null for a legacy attached upload, where nothing measured it. |
byte_size |
For download budgeting. |
voice |
The ElevenLabs voice id for studio narration, "attached" for a legacy editor-uploaded file, or "sample" for placeholder audio (below). Only "sample" should change client behaviour. |
alignment_url |
URL of a JSON sidecar with character-level timings, re-indexed onto the field's plain text. Same lifetime as url. Fetch it lazily; it is hundreds of entries per track. |
Audio on a journey¶
Journey detail already lists every chapter with its framing lines and a content reference to the
piece. Each chapter gains an audio object with the same keys as its framing fields, plus the
piece's own tracks under content, so the whole listen-through is in the payload the screen
already fetches. The journey's own tracks, for its intro pages, practice and outro pages, sit on the
journey.
// GET /api/journeys/12/ (one chapter shown)
{
"id": 12, "slug": "the-long-road-through-grief", "name": "The Long Road Through Grief",
"intro_pages": ["This journey walks with Lewis through a year of grief.", "…"],
"outro_pages": ["You stayed on the road."],
"practice": "Tonight, name one thing you are carrying.",
"audio": {
"intro_pages": [ { "id": "aud_9001", "…": "…" }, null ],
"practice": { "id": "aud_9010", "…": "…" },
"outro_pages": [ { "id": "aud_9020", "…": "…" } ]
},
"chapters": [
{
"id": 88, "slug": "grief-1", "order": 1, "title": "A far-off country",
"opening_question": "What are you carrying that you have not named?",
"intro_pages": ["Lewis wrote this a year after the war ended.", "…"],
"pause_line": "Let it settle.",
"reveal_line": "",
"content": { "type": "passage", "id": 501, "slug": "ourselves-out-of-the-way" },
"audio": {
"opening_question": { "id": "aud_8201", "…": "…" },
"intro_pages": [ { "id": "aud_8202", "…": "…" }, null ],
"pause_line": { "id": "aud_8204", "…": "…" },
"reveal_line": null,
"content": {
"body": { "id": "aud_8123", "…": "…" },
"interpretation": { "id": "aud_8124", "…": "…" },
"reflection_prompt": null
}
},
"state": { "…": "…" }
}
]
}
- Each page list's audio is parallel to its text: a chapter's
intro_pages, and the journey'sintro_pagesandoutro_pages. Same length, same order,nullwhere a page is not narrated. Both come from the same response, so the positions agree. contentaudio mirrors the piece's ownaudioobject. A chapter built on an essay hascontent: { body: … }; one built on a passage has the three passage keys. The piece's detail endpoint returns the identical tracks, so a reader who opens the piece mid-journey plays the same files.- Play order starts with the journey's intro pages. Then, per chapter: opening question, each lead-in page, the piece's fields, pause line, reveal line. The practice and the journey's outro pages come after the last chapter. No separate queue is sent.
- Journey audio is per reader. Entitlement is planned as: the first journey a reader starts is
theirs permanently, editors can mark individual journeys always-free, a reader who has started
nothing yet may open anything, and everything else needs a subscription. The full rules, and
which of them is which, are in the journey access contract.
Once gating lands, a track the reader may not hear is
null, and the response staysprivate, no-storeas journey detail already is. Until then every track is served. The flag beside such a track will not be calledlocked(corrected 2026-09-16):lockedis already a chapter's progression state, and entitlement answers inaccess. Whether the track carries a flag of its own at all is still open.
URL lifetime¶
Track URLs are plain CDN URLs today and expires_at is null. When journey gating lands they
become signed, with an expiry measured in days and refreshed daily, so no listening session can
outlive one. There is no refresh call: if a play ever returns 403, refetch the detail payload,
which is cached for seconds and returns fresh URLs. That is the same path image URLs already use.
Not every field is narrated yet
A field's track is its studio narration when an editor has produced and approved one — and
the recording still matches the field's current words; edited-but-not-re-narrated audio is
withheld, never served mismatched. For everything else, dev and QA serve short sample
clips, marked "voice": "sample", with id and expires_at set to null. Samples are cut
in the real cloned voice and each has a real alignment_url, so playback quality and the
timing parse path are representative, but their words are from one fixed passage and will
not match the field you are playing. Build the player against either; do not demo samples.
Production serves null tracks instead of samples. The shape is identical for both.
Caching¶
Licensed content (the Content access endpoints) is cached by
the app only: Cache-Control: private, max-age=<n> with the same ETag
revalidation, bounded by API_PRIVATE_CACHE_SECONDS. CDNs and shared proxies
must not store it, so no request reaches licensed text without passing the token
check. A 304 is only returned after that check.
Other public reads combine a short public cache window with an ETag for cheap revalidation:
- Within
max-age, a client or CDN serves from cache with no round-trip. That is the performance win of public caching. - After
max-agelapses, send the lastETagback asIf-None-Match. If the content is unchanged, the server returns304 Not Modifiedwith no body. If the content changed (an editor published or edited it), the server returns a fresh200with a newETag.
The window is configurable on the server (API_PUBLIC_CACHE_SECONDS, default 30s) and tuned per
environment. Set it lower for fresher content (for example, 0 in dev and QA, which revalidates every
request through the ETag), or higher to rely on caching more. Staleness is bounded by the window,
and revalidation after it costs only headers. Sending If-None-Match is optional. Omit it, and you
get a fresh 200 once the cache expires.