Skip to content

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 status is 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_entitlements job (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 --all sweep.
  • 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 environment so a per-deployment filter is a small addition if that boundary ever changes.