ADR-0013: RevenueCat as the subscription source of truth¶
Status¶
Accepted
Date¶
2026-09-17
Context¶
The app sells Premium access through Apple and Google in-app purchase. The backend must know who holds Premium so it can gate content, and that knowledge must stay correct as subscriptions renew, lapse, hit billing problems, get refunded, and move between accounts.
Apple and Google report purchases differently. Each has its own server notifications, receipt formats, and edge cases. The backend takes no payment and stores no card data, so its only job is to mirror store state accurately.
Two product constraints shape the model. Premium ships first as one tier, Journey Premium, but a second tier, Audiobook Premium, is planned. The entitlement model must add the second tier as configuration, not a rebuild. And a client success flag must never grant access on its own, because a client can be wrong or replayed.
Decision Drivers¶
- Must unify Apple and Google into one access record without per-store gating logic.
- Must stay correct under duplicate, delayed, and out-of-order store notifications.
- Must support a second tier without a schema change or a webhook rewrite.
- Must never let a client-reported purchase grant access.
- Should give support the product, store, environment, expiry, and sync failures without card data.
- Should avoid a second copy of access state that can drift from the store.
Considered Options¶
Option A: RevenueCat as the store abstraction, mirrored into our own table¶
- Pros: One webhook and one API cover both stores. RevenueCat normalises receipts, grace periods, and transfers. The recommended pattern (fetch current state on any event) absorbs duplicate and out-of-order notifications. A tier is a new entitlement identifier, not new code.
- Cons: A third party in the payment path, and its own dashboard, keys, and pricing to manage.
Option B: Integrate Apple App Store Server Notifications and Google Play RTDN directly¶
- Pros: No third-party dependency or cost.
- Cons: Two notification systems, two receipt-verification paths, and every store edge case is ours to handle and keep current. Far more code to build and maintain for the same result.
Option C: Trust the client's purchase result and store a Premium flag¶
- Pros: Trivial to build.
- Cons: A client flag can be wrong or replayed, so it fails the "never grant from the client" rule. A single flag cannot represent a second tier. Rejected outright.
Decision¶
We will use Option A. RevenueCat is the source of truth for store state. The backend mirrors it
into a ReaderEntitlement table, one row per reader per entitlement, keyed by the RevenueCat
entitlement identifier (its lookup_key). Access is derived from expires_at, with no stored
status column. On any RevenueCat webhook the backend re-fetches the subscriber's current state and
rewrites that reader's rows to match, rather than acting on the event type.
The current-state read uses RevenueCat's REST v2 API, scoped to our project. It combines two
reads: the customer's active entitlements (the authoritative set of what currently grants access,
each with a grace-inclusive expiry) and the customer's subscriptions (store, product, renewal
status, and the lookup_key that names each entitlement). Expiry comes from the active-entitlement
read because it already folds in grace periods; the subscription read supplies the store detail. An
unknown customer returns 404, which the backend reads as no access rather than an error.
The reader is identified to RevenueCat by their Firebase UID as the app user ID, which is also the customer id the v2 endpoints accept, so store state and app account line up without a mapping table.
Consequences¶
Positive¶
- One code path serves both stores. Adding Audiobook Premium is a new entitlement identifier and new products, with no migration and no webhook change.
- The fetch-current-state pattern makes duplicate and out-of-order webhooks harmless, because the last sync reflects what RevenueCat believes now.
- Access is one truth, a date, so it cannot drift from the store. The reader-facing
statusis computed on read. - Access can never come from the client. The refresh endpoint re-pulls from RevenueCat instead of trusting a client result.
- Support sees product, store, environment, expiry, and sync failures from the entitlement rows and the event log, with no card data.
Negative¶
- RevenueCat is a dependency in the access path, with its own account, keys, and cost.
- Holding a transaction open across the RevenueCat fetch to serialize per-reader syncs ties up a database connection for the length of that call. Acceptable at webhook volume; revisit if volume grows.
Risks & Mitigations¶
- Risk: RevenueCat stops retrying a failed webhook after a fixed number of attempts over a couple of hours, so a
brief backend outage could strand a subscriber. → Mitigation: a scheduled
reconcile_entitlementsjob (payments Slice 2, CSL-412) re-syncs active and recently-lapsed subscribers daily. A lost first purchase, which has no entitlement row yet, is covered instead by the app's post-checkout refresh call and a periodic--allsweep. - Risk: a sandbox entitlement could reach a production reader if both environments ever hit one
database. → Mitigation: sandbox and production webhooks route to separate deployments with
separate databases; every row stores its
environmentso a per-deployment filter is a small addition if that boundary ever changes.
Related¶
- ADR-0003: Authentication via Firebase — the Firebase UID is the app user ID RevenueCat keys on.
- ADR-0011: Derived reader state in SQL — same principle, access derived from stored data rather than a duplicated flag.
- Subscriptions — the entitlement contract and the webhook.
- CSL-326 (contract), CSL-327 (discovery), CSL-355 (this build), CSL-356 (the gate that reads it).