Skip to content

API overview

Reference for integrating the mobile app with the CS Lewis API. All endpoints are served under /api/.

Content and Journeys. For the full app-flow contract, see Mobile API full app flow. It covers config, the Home feed, reading, Journeys, and progress and bookmarks, with example payloads. This contract is a proposed mock, pending FE confirmation.

Endpoint guides. Each guide covers an endpoint's concept and behaviour, and the exact schemas are in Swagger. See the Passages API guide, the Themes API guide, and the Home API 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 (app key only). 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). Provisions the account on first call.
DELETE /api/account/ Permanent account deletion (App Store mandate). Optional JSON body {"reason": "..."}. Returns 204.

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.

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.

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": []
}

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 }
}
Key Nominal width
small 360px
medium 720px
large 1080px
xlarge 1440px
xxlarge 2048px
  • 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. Each URL is content-addressed by its filter, so a client can cache a URL indefinitely.

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.

Caching

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.