Skip to content

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_URL and DJANGO_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:

X-API-Key: <key>

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:

Authorization: Bearer <firebase-id-token>

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:

  • 401 means Firebase wants a recent sign-in before a destructive action. Re-authenticate the user and retry.
  • 502 means 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:

POST /api/auth/dev-login/
{ "email": "qa+anything@fueled.com" }

The response:

{ "token": "<bearer token, valid 24 hours>", "email": "qa+anything@fueled.com" }

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:

POST /api/content-access/token/
X-API-Key: <app key>
201
{
  "token": "<opaque string>",
  "expires_at": "2026-10-07T05:15:00Z",
  "expires_in": 900
}
  • 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_in or expires_at and 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:

X-Content-Token: <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:

Accept: application/json; version=1.0

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

{ "api_version": "1.0" }

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_type is a stable, machine-readable code. Branch on it.
  • errors is always a list. Field errors carry a field key, and non-field errors omit it. message is 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:

GET /api/...?limit=30&offset=60
{
  "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 (![alt](https://…)).

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 null when the piece carries no image. It is never an empty object.
  • width and height are 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 reports width: 800 under xlarge and xxlarge, 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.
  • original is the uploaded file, untouched. It keeps the upload's format and full size. Use it only where re-encoding would harm the image: a journey emblem, 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 audio always has body, interpretation and reflection_prompt; a writing's or an article's always has body. Note the one naming gap: a passage's audio.body narrates the text the payload shows as quote, and audio.interpretation narrates interpretation.body. Each value is a track or null when the field is blank or not narrated yet. Show the headphones control when any track is non-null.
  • A passage card's audio is its detail payload's audio. 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 the audio object.
  • 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's intro_pages and outro_pages. Same length, same order, null where a page is not narrated. Both come from the same response, so the positions agree.
  • content audio mirrors the piece's own audio object. A chapter built on an essay has content: { 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 stays private, no-store as journey detail already is. Until then every track is served. The flag beside such a track will not be called locked (corrected 2026-09-16): locked is already a chapter's progression state, and entitlement answers in access. 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:

Cache-Control: public, max-age=<n>
ETag: W/"<hash of the response payload>"
  • Within max-age, a client or CDN serves from cache with no round-trip. That is the performance win of public caching.
  • After max-age lapses, send the last ETag back as If-None-Match. If the content is unchanged, the server returns 304 Not Modified with no body. If the content changed (an editor published or edited it), the server returns a fresh 200 with a new ETag.

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.