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_daysandannual_reminder_working_daysare 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_daysis 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.
| 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_ENABLEDgates 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.
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.
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
Authorizationheader against a configured token. Set the same value in the dashboard's webhook authorization field. A missing or wrong value is a401. 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-Signatureheader. It parsestandv1, 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.