Skip to content
Skip to content
Goodspeed

BUILT INTO EVERY GOODSPEED APP

RevenueCat Subscriptions + Paywall

RevenueCat is initialized at app boot with a config-driven paywall screen that reads pricing tiers and copy from gas.config, wiring the full purchase, restore, and trial flow without any custom App Store or Play Store receipt parsing.

  • Tier: Common
  • Status: Config-toggled
  • Config: features.inAppPurchases.enabled

WHY IT MATTERS

Building a mobile subscription business involves more infrastructure than most teams anticipate. The payment sheet itself is the easy part. What actually takes weeks to get right is the state machine that sits around it: tracking whether a user has an active entitlement across two different app stores, handling trial periods that expire mid-session, reconciling a subscription that was purchased on one device and restored on another, and surfacing the paywall at precisely the right moment without interrupting users who are already paying. Attempting this with direct StoreKit or Play Billing calls means writing the same receipt-parsing and state-sync logic that hundreds of developers have already debugged, and maintaining it against each year's new OS release notes. The template solves this by integrating RevenueCat as the single source of truth for subscription state. RevenueCat abstracts both stores behind one API, handles cross-device entitlement sync automatically, and provides a dashboard for modifying offerings without a new app build.

Initialization happens at app boot via initRevenueCat() in lib/revenuecat.ts. The function reads platform-specific API keys from gasConfig.backend.revenuecat, branches on Platform.OS, calls Purchases.configure(), and emits a PostHog event so you can correlate SDK initialization with downstream purchase funnel events. On web the function is a no-op. After a successful auth, identifyRevenueCatUser() calls Purchases.logIn() with the Supabase user ID so RevenueCat links entitlements to your user record across devices. On logout, resetRevenueCatUser() calls Purchases.logOut() to sever that link. The paywall screen at app/(modal)/paywall.tsx reads its tier names, feature bullets, pricing copy, and trial period length entirely from gasConfig.features.inAppPurchases.tiers, with no hardcoded copy and no separate CMS. The useSubscription hook fetches the user's current subscription_tier from Supabase profiles at mount, loads RevenueCat offerings into state, and registers a CustomerInfoUpdateListener that triggers a refresh whenever an entitlement changes externally, such as when a subscription renews or lapses in the background.

HOW IT IS WIRED

Real code from the GAS template

The code below is drawn from lib/revenuecat.ts in the gas-template repository. This is the code your generated app gets, not pseudocode, not a description of intent.

// lib/revenuecat.ts -- platform-aware SDK init + identity wiring
import { Platform } from 'react-native';
import { isWeb } from './platform';
import { captureEvent } from './posthog';
import { captureException, addBreadcrumb } from './sentry';
import { gasConfig } from '../gas.config';

const REVENUECAT_APPLE_KEY  = gasConfig.backend.revenuecat.iosKey;
const REVENUECAT_GOOGLE_KEY = gasConfig.backend.revenuecat.androidKey;

/** Initialize RevenueCat at app boot. No-op when disabled, key absent, or on web. */
export async function initRevenueCat(): Promise<void> {
  if (isWeb || !Purchases) return;
  if (!gasConfig.features.inAppPurchases.enabled) return;
  const apiKey = Platform.OS === 'ios' ? REVENUECAT_APPLE_KEY : REVENUECAT_GOOGLE_KEY;
  if (!apiKey) return;
  if (__DEV__ && LOG_LEVEL) Purchases.setLogLevel(LOG_LEVEL.DEBUG);
  await Purchases.configure({ apiKey });
  captureEvent('revenuecat_initialized', { platform: Platform.OS });
  addBreadcrumb('iap', 'RevenueCat initialized');
}

/** Associate the signed-in user ID so cross-device subscription state syncs. */
export async function identifyRevenueCatUser(userId: string): Promise<void> {
  if (isWeb || !Purchases) return;
  try { await Purchases.logIn(userId); }
  catch (e) { captureException(e, { component: 'revenuecat', action: 'identify', userId }); }
}

Source: goodspeed-apps/gas-template lib/revenuecat.ts

HONEST LIMITS

When RevenueCat Subscriptions + Paywall is the wrong choice

RevenueCat is the right choice for any app that needs to manage subscription state across iOS and Android, handle free trials, or let users restore purchases after a reinstall. It is not the right choice for every monetization model. If your app sells a single one-time unlock and nothing else, spinning up a RevenueCat account, handling the SDK initialization, and writing the subscription state machine is genuine overkill. A single call to Purchases.purchaseStoreProduct() or a direct StoreKit transaction listener is the leaner path; the template's purchaseProduct() function in lib/revenuecat.ts exposes exactly this without the subscription machinery. Similarly, if your app is a B2B tool billed via invoice outside the app stores, or an enterprise internal app distributed via MDM where users never interact with the App Store, disabling gasConfig.features.inAppPurchases.enabled removes all RevenueCat code paths from the bundle entirely. There is also a geographic and pricing-model constraint worth naming. Apple and Google each take a 15 to 30 percent revenue share on in-app purchases, with the reduced 15 percent rate applying to subscriptions after 12 months and to small developers under certain annual revenue thresholds. For apps targeting markets where local purchasing power makes the effective price prohibitive after the store cut, or for SaaS products where the web billing path is already the dominant channel and mobile is a companion surface, routing subscription billing through the App Store may not be financially viable. The template makes it straightforward to run a web-only billing flow for iOS web users while disabling in-app purchases on the native side: set features.inAppPurchases.enabled to false, direct users to your existing web checkout, and deep-link back into the app after purchase with the entitlement already written to profiles.subscription_tier by your server-side Stripe webhook.

Tier: Common · Config-toggled

  1. Evaluate your use case

    Check whether revenuecat subscriptions + paywall aligns with your target audience, platform constraints, and regulatory environment before enabling it.

  2. Audit the config

    The `features.inAppPurchases.enabled` flag controls this feature. Set it to false in gas.config.ts to disable the feature entirely with no residual code paths.

  3. Seek alternatives

    If the built-in implementation does not fit, the generated codebase is standard React Native + Expo code. Any library in the Expo ecosystem can replace the default.

APPS USING THIS FEATURE

Every generated Goodspeed app includes revenuecat subscriptions + paywall. Browse the ideas catalog to see apps across all categories that ship with this feature wired in.

CAPABILITIES

RevenueCat Subscriptions + Paywall capability breakdown

Concrete dimensions of what the built-in revenuecat subscriptions + paywall implementation covers. These reflect the actual template code, not a marketing summary.

ItemDescriptionStrength
SDK initinitRevenueCat() runs at app boot. It reads iOS/Android API keys from gasConfig.backend.revenuecat and is a no-op when the feature flag is off, the key is absent, or the platform is web.Config-driven
Cross-platformRevenueCat abstracts Apple StoreKit 2 and Google Play Billing. One purchasePackage() call works on both platforms. All IAP functions return null on web without throwing.iOS + Android
Offerings cachegetOfferings() caches the result in memory for five minutes (OFFERINGS_CACHE_TTL_MS). Stale cache is served on network failure so the paywall always has pricing data to render.5-min TTL
Subscription stateuseSubscription() reads the user tier from profiles.subscription_tier in Supabase and syncs RevenueCat offerings. A CustomerInfoUpdateListener refreshes state on external changes such as renewal or cancellation.Supabase + listener
Purchase safetyA purchaseInProgressRef guard prevents double-tap races. Invalid package IDs are caught before the native purchase sheet opens, and errors surface via state.error rather than uncaught exceptions.Race-safe

COMMON QUESTIONS

Does the paywall work on both iOS and Android without separate configuration?

Yes. The initRevenueCat() function branches on Platform.OS and uses the appropriate API key from gasConfig.backend.revenuecat. RevenueCat handles StoreKit 2 and Google Play Billing underneath, so purchasePackage() is a single call that works on both platforms. The paywall screen reads all its pricing tiers and feature bullets from gasConfig.features.inAppPurchases.tiers, so you configure your offering once in gas.config.ts and both platforms reflect the change. On web, all IAP functions are no-ops that return null without throwing, so the same component tree renders without error in a web export. Android channel and iOS entitlement differences are handled by RevenueCat on the server side; your app code never branches on platform for the core subscription logic.

How does subscription state stay in sync after a user restores purchases on a new device?

Calling restore() from the useSubscription hook triggers rcRestorePurchases(), which calls Purchases.restorePurchases() to re-validate all prior transactions against the store receipts. After a successful restore, the hook calls refresh() which fetches the updated subscription_tier from Supabase profiles. The CustomerInfoUpdateListener registered at hook mount also fires automatically whenever RevenueCat detects an entitlement change, including renewals and cancellations that happen while the app is in the background. This means the app never needs to poll: state updates arrive as events and the hook refreshes synchronously. For cases where the Supabase tier is stale relative to the RevenueCat entitlement, a server-side webhook should write the authoritative tier back to profiles.subscription_tier whenever RevenueCat fires a subscription status event.

How does trial detection work, and can I customize the trial length per tier?

The useSubscription hook computes isTrialing by comparing the trialEndsAt timestamp from profiles against the current time. The trial_ends_at column is populated by your server-side RevenueCat webhook handler when a trial starts. The trial length per tier is configured in gasConfig.features.inAppPurchases.tiers via the trialDays field on each tier object. The paywall screen reads TRIAL_TIER from this array to find the first tier with a non-zero trialDays and surfaces the trial copy dynamically. Changing the trial length is a gasConfig edit, not a code change. Note that the actual trial period enforced by the App Store or Play Store is controlled by your RevenueCat offering configuration in the RevenueCat dashboard; the gasConfig trialDays value is used for display copy only and should match what you have configured in RevenueCat.

What happens if a user taps the purchase button twice before the sheet appears?

The useSubscription hook uses a purchaseInProgressRef guard (a React ref rather than state, to avoid triggering re-renders) that is set to true at the start of a purchase attempt and cleared in the finally block after the attempt completes. If purchase() is called a second time while a purchase is in progress, it returns early without initiating a second native payment sheet. This prevents the double-tap race condition where two concurrent purchasePackage() calls can each succeed and charge the user twice. The guard is also applied in purchaseOneTime() for consumable products. Beyond the guard, invalid package identifiers are validated against offerings.current.availablePackages before the native sheet is shown, surfacing a typed error via state.error rather than letting the SDK throw an unhandled rejection.

GET IT BUILT INTO YOUR APP

Score your idea and get revenuecat subscriptions + paywall wired in from day one