Skip to content

Subscriptions

Status: Live

The entitlement list, the on-demand refresh, the entitlements block on the account payload, and the RevenueCat webhook all ship in CSL-355. Paywall copy ships in CSL-472.

A subscription gives a reader Premium access. The reader buys it in the app through Apple or Google, and the backend recognises it and unlocks the paid content. The backend never sees a card number and never takes a payment. It is told about the purchase and reflects it.

This page explains how a store purchase becomes access in our system, and why the parts are shaped the way they are. The endpoints are further down this page.

flowchart LR
    store(["Store account · Apple or Google"]) -->|pays| rc["RevenueCat · keyed by Firebase UID"]
    rc -->|"webhook"| be["Backend sync"]
    be -->|"writes"| ent[("ReaderEntitlement")]
    ent -->|"read by"| gate(["Content gate"])

The tiers

Journey Premium is the tier that ships first, sold as a monthly and an annual subscription with no trial. It unlocks the full Journey library and its narration, and every new Journey as it is released. There is no trial: every reader instead gets one free Journey of their choice, claimed the first time they start one. RevenueCat's product catalogue holds the current prices; the backend keys on the entitlement identifier, not the amount.

Audiobook Premium is planned as a second tier and is not built yet. It would unlock licensed HarperCollins audiobooks. It waits on the rights and delivery questions in the audiobook DRM track, so no date is set.

The model is built so that adding the second tier is configuration, not a rewrite. That constraint drove the whole design, and it is why nothing in the system stores Premium as a single yes or no.

Two accounts are in play

When a reader subscribes, two separate identities are involved.

  • The store account is the Apple ID or Google account signed into the phone. This is who pays. Apple and Google attach the subscription to it.
  • The app account is the reader's Firebase login inside our app. This is who we grant Premium to.

The subscription physically lives with the store account. We never touch it. RevenueCat sits between the two and links a store purchase to an app account, keyed by the Firebase UID. That UID is the app user ID RevenueCat knows the reader by.

The backend is the authority

The app reflects access. It never grants it. A client success flag alone never opens paid content, because a client can be wrong, replayed, or tampered with. The backend decides, and every gate reads the backend's record.

That record is the ReaderEntitlement table. One row is one reader holding one entitlement, keyed by the RevenueCat entitlement identifier (journey_premium today). Access is one question: does an active row exist, meaning expires_at is in the future or absent. There is no status column. A reader either has a live entitlement row or does not.

Keeping access as a date, not a flag, matters because the store already bakes grace periods and billing retries into the expiry it reports. A status column would be a second copy of the truth, and the two would drift. The reader-facing status (active, cancelled, billing_issue, expired) is computed from the row when someone asks, so it cannot fall out of step.

How the record stays true

RevenueCat sends a webhook on every subscription event: a purchase, a renewal, a cancellation, an expiry, a billing problem, a transfer. The backend does not read the event type to decide what changed. On any event it does one thing: it re-fetches the reader's current state from RevenueCat's API and rewrites the reader's entitlement rows to match. Two reads make up that state: the entitlements that currently grant access, each with a grace-inclusive expiry, and the subscriptions behind them, which carry the store, product, and renewal detail.

sequenceDiagram
    participant RC as RevenueCat
    participant BE as Backend
    RC->>BE: webhook (any event type)
    BE->>RC: GET active entitlements + subscriptions
    RC-->>BE: current access + store detail
    BE->>BE: rewrite this reader's rows to match

This is deliberate. Parsing each event type means handling every combination of them correctly and in order. Fetching the current state means the last write always reflects what RevenueCat believes right now, whatever the event was. RevenueCat recommends this pattern, and it makes an out-of-order or duplicate webhook harmless.

Webhooks are not enough on their own. RevenueCat retries a failed delivery a fixed number of times over a couple of hours, then stops for good. A reader whose backend was briefly down during those two hours would be stranded. So a daily reconciliation job re-checks readers who already hold a grant and corrects any lifecycle change (renewal, cancellation, grace, expiry) a dropped webhook missed, within a day. The webhook is the fast path; reconciliation is the safety net. A lost first purchase has no grant row yet, so it is caught instead by the app's authenticated refresh call right after checkout, with an occasional full --all sweep as a backstop.

When a reader reports "I paid but don't have access", support can inspect the record without a database console: a read-only admin lists a reader's current entitlements and the raw subscription events received for them. It is read-only for everyone, since these rows mirror RevenueCat and are only ever written by the sync.

Restore and transfer across accounts

Every app must offer a Restore button. It tells the store "this phone already paid, light my access back up", which is how a reinstall gets its subscription back without paying again.

Restore gets interesting when one store account has paid but a different app account taps Restore. Picture one Apple ID and two Firebase logins on the same phone: the reader signs out of account A, signs into account B, and taps Restore. One payment, two app accounts that could claim it.

RevenueCat's default, which we keep, is to transfer the purchase. Premium moves to whichever app account restored last, and the previous account loses it. Only one account holds a paid subscription at a time, and it follows the person. RevenueCat fires a TRANSFER webhook naming both accounts, and the backend re-syncs both through the same current-state fetch: the old account's row lapses, the new account's row is granted.

We keep transfer on because the alternative breaks the honest case. Someone with a new phone or a new email must be able to move their own subscription, and blocking that produces the worst support ticket there is: "I paid and I can't get in." The trade-off is that sharing one purchase across two active accounts is not possible, which is the intended limit.

The free Journey does not transfer. It is per-account product state, not a store purchase, so RevenueCat has nothing to move and account B keeps its own. See the free-Journey rule in the journey access work for how that grant behaves.

What Premium unlocks

Premium gates the whole Journey, its text and its narration together, not the audio alone. A reader without Premium can still read individual passages, listen to a single passage, save and reflect, and open one free Journey of their choice. A locked Journey still returns a preview so the app can render a paywall from real data. The gating rules and the paywall contract are built in the journey access work and documented there, not here. This page owns the store side: how a purchase becomes an entitlement row.

Paywall copy: GET /api/payments/paywall/

The editable claims on the Journey Premium paywall — headline, subtitle, benefit lines, and the renewal-reminder schedule disclosed next to the purchase button (CSL-472, CSL-383). Public, app-key-gated, and cacheable like any other editorial content: the copy is the same for every reader and editors publish it rarely.

{
  "headline": "Continue your Journey with Premium",
  "subtitle": "Continue every Journey with beautiful narration and unlock the complete library.",
  "benefit_lines": ["10+ guided Journeys", "Fully narrated experiences", "New Journeys added regularly"],
  "annual_renewal_reminder_days": 60,
  "monthly_reminder_working_days": 3,
  "annual_reminder_working_days": [21, 10]
}
  • Prices, billing periods and the annual saving are never here. CSL-353 reads those from the RevenueCat package at runtime — this endpoint carries editorial copy only.
  • Never blank. A field an editor leaves empty falls back to the seed wording from the 18 Sep Payments designs, so the app never renders an empty paywall.
  • The reminder schedule is shared with the reminder emails. monthly_reminder_working_days and annual_reminder_working_days are the working days (Monday to Friday, minus the subscriber's public holidays) before a renewal charge that the reminder email goes out. The annual list is earliest first and may hold one value if editors switch the first reminder off. The reminder job reads the same CMS fields, so the paywall disclosure and the emails can't disagree.
  • annual_renewal_reminder_days is deprecated. It is the calendar-day window disclosed before counsel moved to working days. It is still served until the app reads the working-day fields; the reminder job does not use it.
  • Every edit is audited. Wagtail's own settings edit view logs who changed the copy and when, the same as any other CMS edit — the paywall is a claim made to buyers.

Subscription notice emails

The backend emails paying subscribers in the US, Canada, UK and Europe (EU, EEA, Switzerland), using counsel-reviewed copy (CSL-383, CSL-425). Neither the stores nor RevenueCat send these for in-app purchases. There is no endpoint: the app only links the reader to Manage Subscription (CSL-424), which the emails tell readers to use.

Email When Who
Renewal reminder Monthly: 3 working days before a charge, about every six months. Annual: 21 and 10 working days before renewal. Subscribers set to auto-renew
Cooling-off notice The day an annual subscription renews Annual subscribers only
  • Dates are UTC. The backend stores no subscriber timezone. The cooling-off period is 14 days counting the renewal day (pending counsel's confirmation).
  • Working days skip weekends and the subscriber's public holidays (by store country, the UK's when unknown), so a holiday moves a reminder earlier.
  • A plan upgrade from monthly to annual does not trigger a cooling-off email.
  • Before sending, the backend rechecks the subscription: a cooling-off email is dropped if the renewal was fully refunded or the subscription moved to another account; a reminder is dropped if the subscription no longer renews on that date.
  • Amounts are as charged, in the purchase currency, from the RevenueCat purchase and renewal webhooks. A reminder states the last amount charged.
  • Every email is logged with its recipient, subject, body and send time (Django admin → Subscription notices). Failed sends are retried by a daily job, then marked failed.
  • Off until launch is decided. SUBSCRIPTION_NOTICES_ENABLED gates sending; while it is off, notices are still recorded so their content can be checked.

Endpoint map

What Endpoint
The reader's entitlements GET /api/account/entitlements/
Refresh them from the store now POST /api/account/entitlements/refresh/
Entitlements alongside the profile GET /api/account/ (the entitlements field)
RevenueCat delivers subscription events POST /api/payments/revenuecat/webhook/
Paywall copy GET /api/payments/paywall/

The three account routes need a bearer token and are never cached. They live under /api/account/ for that reason. The webhook is called by RevenueCat, not the app, and authenticates differently (see The webhook). The paywall-copy endpoint is public like the account routes are not — no bearer token, cached, the same response for every caller.

The entitlement object

Every account endpoint returns entitlements in one shape, so the app reads the same object everywhere.

{
  "key": "journey_premium",
  "status": "active",
  "expires_at": "2026-10-17T09:12:44Z",
  "will_renew": true,
  "store": "app_store",
  "environment": "production",
  "product_id": "journey_premium_annual"
}
Field Meaning
key The RevenueCat entitlement identifier. journey_premium today. A second tier is a new key, not a new field.
status Computed from the row, one of the four values below. Read this, not the raw dates, to decide what to show.
expires_at ISO 8601, or null for an entitlement with no expiry (a lifetime or promotional grant, which reports active). The reader has access while this is in the future.
will_renew Whether the store expects to auto-renew at expires_at.
store Where the purchase was made: app_store, play_store, or test_store in non-production.
environment production or sandbox, verbatim from RevenueCat.
product_id The store product that granted it, the same id on both stores: journey_premium_annual or journey_premium_monthly. Match it to the store price to show the plan and price. Play's :<base-plan> suffix is removed. Empty when the backend has not resolved it yet; the next sync fills it in.

status values:

Value Meaning
active The reader has access, and the subscription is set to renew or the entitlement has no expiry (lifetime or promotional).
cancelled The reader still has access, paid through expires_at, but auto-renew is off. It will lapse then unless they resubscribe.
billing_issue The reader still has access, but the store flagged a payment problem. Surface a fix-payment nudge.
expired No access. The entitlement lapsed.

The reader's entitlements: GET /api/account/entitlements/

Every entitlement the reader holds, active or lapsed, each as the object above.

{ "entitlements": [ { "key": "journey_premium", "status": "active", "…": "…" } ] }

A reader who has never subscribed gets an empty list. The response is Cache-Control: private, no-store. 401 without a valid bearer token.

Refresh from the store: POST /api/account/entitlements/refresh/

Pulls the reader's current state from RevenueCat, updates the rows, and returns the same list.

Use it right after a purchase or a restore, so access does not wait on webhook timing. The webhook still arrives and syncs the same state; this is the immediate path for the buying reader.

No request body. Throttled to 5 requests per minute per reader.

Status When
200 Synced. The body is the refreshed entitlement list.
429 Throttled. Back off and retry.
502 RevenueCat could not be reached. Nothing changed; safe to retry.
401 No valid bearer token.

Entitlements on the account payload: GET /api/account/

GET /api/account/ carries an entitlements array in the same shape, so the app knows access state from the one call it already makes at sign-in without a second request.

{ "uid": "…", "email": "…", "name": "…", "joined_at": "…", "entitlements": [ … ] }

The array is empty for a reader with no entitlements. See Account endpoints.

The webhook

POST /api/payments/revenuecat/webhook/

RevenueCat calls this on every subscription event. The app never calls it. It is documented here so whoever configures the RevenueCat dashboard knows the contract.

The backend does not act on the event type. On any event it re-fetches the affected subscriber's current state from RevenueCat and rewrites that reader's entitlement rows to match. A TRANSFER re-syncs both the old and the new app user ID.

Authentication is RevenueCat's own, set in the RevenueCat dashboard, not the app key or a Firebase token:

  • Authorization header. The backend compares the request's Authorization header against a configured token. Set the same value in the dashboard's webhook authorization field. A missing or wrong value is a 401. With no token configured the backend rejects every webhook, so the token must be set before RevenueCat can sync.
  • HMAC signature (optional). If HMAC signing is enabled on the integration, the backend also verifies the X-RevenueCat-Webhook-Signature header. It parses t and v1, recomputes HMAC-SHA256 over "<t>.<raw_body>", and rejects a signature older than five minutes to block replays.
Status When
200 Stored and synced. A duplicate of an already-processed event also returns 200.
400 The body is not a valid event object. RevenueCat gains nothing by retrying.
401 Bad or missing authorization token, or a bad signature.
500 The sync failed, for example RevenueCat was unreachable. The event is recorded with its error, and RevenueCat retries.

RevenueCat retries a failed delivery a fixed number of times over a couple of hours, then stops. A daily reconciliation job re-syncs readers who already hold a grant, correcting lifecycle changes the retries missed within a day. A lost first purchase (no grant row yet) is covered by the refresh call after checkout and a periodic --all sweep.

Caching

The account entitlement endpoints are private, no-store, because the payload is per reader. Do not cache them across readers. See Caching.