Subscriptions

Account-bound Pro entitlement through RevenueCat and the Supabase read model.

The Kotlin template sells Pro through Google Play and RevenueCat, but the app does not treat RevenueCat as the entitlement source of truth. Purchases are written into the shared Supabase subscriptions table, and app gates read that account row.

What it provides

SurfaceStatusImplementation
RevenueCat SDK lifecycleIntegratedPurchasesController configures RevenueCat when REVENUECAT_ANDROID_KEY is present
Account identityIntegratedRevenueCat logs in with the Supabase user UUID as app_user_id
Entitlement stateIntegratedEntitlementController reads public.subscriptions through SubscriptionRepository
PaywallIntegratedPaywallScreen renders the active offering's packages
Manage subscriptionIntegratedManageSubscriptionScreen shows RevenueCat/Google Play management state
Promo codesIntegratedRedeemCodeScreen opens the Google Play redeem URL
Pro content exampleRemovable demofeature/guide/ uses PremiumGate for the bundled developer guides

Key files

Entitlement flow

Google Play purchase
  -> RevenueCat customer event
  -> revenuecat-webhook Edge Function
  -> public.subscriptions, provider = play_store
  -> EntitlementController.state
  -> Home license card, Me row, PremiumGate, guide locks

The differentiator is account-bound entitlement. PurchasesController observes SessionController.currentUser, calls RevenueCat logIn(userId), and uses the Supabase Auth UUID as RevenueCat app_user_id. The webhook rejects non-UUID app_user_id values, so anonymous RevenueCat customers cannot unlock account-gated surfaces.

EntitlementController is the app authority. It fetches the signed-in user's subscriptions row, exposes EntitlementState, refreshes after sign-in, clears on sign-out, refreshes on RevenueCat customer-info updates, and polls after a purchase while the webhook catches up.

RevenueCat still matters: it provides the Google Play Billing UI, active offering, prices, restore flow, customer-info updates, and management URL. It is not the gate that decides whether Pro content is unlocked.

Product model

The template expects one RevenueCat entitlement:

const val PRO_ENTITLEMENT_ID = "pro"

That constant lives in PurchasesController.Companion. Change it there and in your RevenueCat dashboard if you rename the entitlement.

Recommended Google Play product ids:

Product idStore typeSupabase product_typeAccess model
pro_monthlyAuto-renewing subscription, monthly base planmonthEntitled while active, trialing, or canceled inside the paid period
pro_yearlyAuto-renewing subscription, yearly base planyearSame rule, yearly period
pro_lifetimeOne-time in-app productone_timeEntitled indefinitely once active

The product ids are not hardcoded in the Android app. RevenueCat offerings control the subset, prices, localized labels, and package identifiers returned to the paywall.

Paywall behavior

PaywallViewModel reads the current offering and sorts packages in this order:

  1. Monthly
  2. Annual
  3. Lifetime
  4. Any other RevenueCat package type

If Annual exists, it is selected by default. Otherwise the first sorted package is selected. The UI renders whatever subset the offering returns, so a build can ship with only monthly/yearly, only lifetime, or all three.

PaywallScreen behavior by state:

StateUser-facing behavior
RevenueCat not ready yetLoading spinner
Supabase entitlement already activeOwned-license card
REVENUECAT_ANDROID_KEY missingInfo card: in-app purchases are unavailable for this build
Offering has no packagesRetryable load-error block
Packages presentSelect package, continue purchase, restore, redeem code, legal links

Annual packages show a "Most popular" badge and savings when monthly and annual prices make the savings meaningful. Lifetime packages show "Best value".

Gated guides

The in-app Pro example is the developer guide surface:

  • GuideIndexScreen shows the guide list and lock icons when the user is not entitled.
  • GuideDocumentScreen wraps the document body in PremiumGate.
  • PremiumGate shows content only when EntitlementState.isEntitled() is true.

Those guides live under the removable Showcase leaf. They are useful as a pattern for your own Pro-only features, but you can delete or replace them when the Showcase tab is removed.

Entitlement rules

Subscription.isEntitled(now) keeps the rules intentionally small:

Subscription statusProduct typeEntitled when
active / trialingone_timeAlways
active / trialingmonth / yearperiod_end is in the future
canceledmonth / yearperiod_end is still in the future
unpaid / expired / unknownAnyNever

Unknown enum values decode to Unknown, which locks by default until the app explicitly supports the new value.

Cross-platform row ownership

public.subscriptions has one row per user. The RevenueCat webhook applies same-provider Play Store events every time because Play is authoritative for its own renewals, cancellations, billing issues, and expirations.

When a different provider already owns the row, the incoming event takes over only if it extends period_end. That means a shorter Play Store subscription does not shorten a longer Creem or App Store grant, and a shorter sibling provider event does not shorten an active Play Store period.

Verify

  • Add REVENUECAT_ANDROID_KEY, sign in, and confirm RevenueCat logs in with the Supabase user UUID.
  • Open the paywall and confirm the active offering's packages render.
  • Remove the key, rebuild, and confirm the paywall shows the unavailable info state instead of crashing.
  • Make a sandbox purchase and confirm the subscriptions row, not only RevenueCat CustomerInfo, unlocks PremiumGate.
  • Cancel a subscription in Play testing and confirm access remains until period_end.

On this page