BUILT INTO EVERY GOODSPEED APP
In-App Purchases
RevenueCat runs the StoreKit and Play Billing purchase flow on iOS and Android, while the runtime access check reads profiles.subscription_tier from Supabase, so gating a feature is one indexed database read with no SDK round-trip. Restore purchases and a config-driven paywall are included; web is a no-op.
- Tier: Common
- Status: Config-toggled
- Config: features.inAppPurchases.enabled
WHY IT MATTERS
Mobile payments have two hard parts: running the store transaction correctly on each platform, and deciding at runtime whether a given user is entitled to a paid feature. The template splits them. RevenueCat (react-native-purchases) handles the transaction through StoreKit on iOS and Play Billing on Android, with the platform's API key selected from gasConfig.backend.revenuecat. The runtime entitlement check does not call the RevenueCat SDK on the hot path: usePaywall.checkAccess and useSubscription read profiles.subscription_tier straight from Supabase and compare it against the ordered tiers array in gas.config.ts. Gating a screen is one indexed database read, not a network call to a payments vendor, so a paywall check stays fast even on a cold launch.
The purchase flow in hooks/useSubscription.ts validates that the requested package exists in the live offerings before it charges, guards against double-taps with an in-flight ref, and emits purchase_initiated, purchase_completed, and purchase_failed events into PostHog so the funnel is visible. RevenueCat is the transaction processor; the user's tier is written to profiles.subscription_tier by a RevenueCat webhook, and an addCustomerInfoUpdateListener triggers a refresh when subscription state changes outside the app. Restore Purchases calls the SDK and then re-reads the Supabase tier. The paywall screen renders Plans, one-time Products, and consumable Credits tabs driven entirely by config, showing only the models you turn on. On web every purchase function returns early and the buttons are disabled, since there is no store there.
HOW IT IS WIRED
Real code from the GAS template
The code below is drawn from hooks/useSubscription.ts in the gas-template repository. This is the code your generated app gets, not pseudocode, not a description of intent.
// hooks/useSubscription.ts
const purchase = useCallback(async (packageId: string) => {
if (purchaseInProgressRef.current) return; // ignore double-taps
purchaseInProgressRef.current = true;
setIsLoading(true);
captureEvent('purchase_initiated', { productId: packageId });
try {
// Validate the package exists in current offerings before charging
const availablePackage = offerings?.current?.availablePackages?.find(
(p) => p.identifier === packageId,
);
if (!availablePackage) {
const msg = `Package "${packageId}" not found in available offerings`;
captureEvent('purchase_failed', { error: msg, productId: packageId });
throw new Error(msg);
}
await rcPurchasePackage(packageId);
await refresh(); // re-read profiles.subscription_tier from Supabase
captureEvent('purchase_completed', { productId: packageId });
} catch (e) {
captureException(e, { component: 'useSubscription', action: 'purchase' });
throw e;
} finally {
setIsLoading(false);
purchaseInProgressRef.current = false;
}
}, [refresh, offerings]);Source: goodspeed-apps/gas-template → hooks/useSubscription.ts
HONEST LIMITS
When In-App Purchases is the wrong choice
Every purchase here routes through RevenueCat and the platform stores, which Apple and Google reserve for digital goods and services consumed inside the app. Selling physical products, or billing through your own web checkout, has to go through a different processor; the template ships a separate Stripe-style create-payment-intent edge function that is intentionally not wired into this feature. Pushing physical-goods sales through in-app purchases gets the build rejected at review, so route those flows outside the store billing path from the start. On web, all of lib/revenuecat.ts returns early and the paywall disables its buttons, so there is no purchase path in the browser. One thing to verify before launch: the subscription tier the gate reads is written by a RevenueCat webhook, and the validate-purchase edge function that records one-time and consumable purchases still carries a TODO to verify the receipt against RevenueCat's server API. Wire and test that webhook, and tighten server-side verification, before you let the stored tier gate anything valuable.
Tier: Common · Config-toggled
Evaluate your use case
Check whether in-app purchases aligns with your target audience, platform constraints, and regulatory environment before enabling it.
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.
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
Apps built with In-App Purchases
These apps were generated by Goodspeed and use in-app purchases as a core part of their experience. Each link goes to the full app marketing page.
CAPABILITIES
In-App Purchases capability breakdown
Concrete dimensions of what the built-in in-app purchases implementation covers. These reflect the actual template code, not a marketing summary.
| Item | Description | Strength |
|---|---|---|
| Cross-platform store flow | react-native-purchases runs StoreKit on iOS and Play Billing on Android; the API key is selected per platform from gasConfig.backend.revenuecat, with a web stub returning empty entitlements. | iOS + Android |
| Database-backed entitlement | usePaywall.checkAccess reads profiles.subscription_tier from Supabase and compares it against the ordered tiers array; no RevenueCat SDK call happens at gate time. | Supabase read |
| Cached live offerings | getOfferings fetches Purchases.getOfferings() behind a 5-minute module cache and returns the last good offerings on error, so the paywall renders real packages without refetching on every open. | 5-min cache |
| Restore + external sync | restorePurchases calls the SDK then re-reads the Supabase tier; addCustomerInfoUpdateListener triggers a refresh when subscription state changes outside the app. | Built in |
| Config-driven paywall | app/(modal)/paywall.tsx renders Plans (subscriptions), Products (one-time), and Credits (consumable) tabs from config; the tab bar appears only when more than one model is enabled. | 3 models |
COMMON QUESTIONS
What is the runtime source of truth for whether a user is entitled?
profiles.subscription_tier in Supabase. usePaywall.checkAccess queries that column directly and compares the value against the ordered tiers array in gas.config.ts, where index zero is free and higher indexes are higher tiers. RevenueCat is the transaction processor, not the gate: the SDK is not called when a screen checks access. That keeps gating fast, and it means the tier in Supabase has to be kept current, which is the job of the RevenueCat webhook described below.
How does a purchase make it into profiles.subscription_tier?
Through a RevenueCat webhook, not the client. After a successful purchase the client calls refresh() to re-read the tier, but the authoritative write happens server-side when RevenueCat posts the entitlement change to your webhook handler, which updates profiles.subscription_tier. If the webhook has not yet processed when the user returns, a restore or refresh from the SDK alone will not change checkAccess results until that server write lands. Set up and test the webhook before launch so tier changes are reliable.
How do I test purchases in sandbox?
RevenueCat picks up the platform sandbox automatically: use StoreKit sandbox credentials on iOS or a Play Store test track on Android, and the same code path runs against test products with no separate branch. The template sets Purchases.setLogLevel(LOG_LEVEL.DEBUG) in __DEV__ so you can watch the SDK's calls during testing. The unit tests in __tests__/lib/revenuecat.test.ts mock react-native-purchases entirely, so CI never touches the real SDK or the stores.
Is the purchase receipt verified server-side?
Partly, and this is worth checking before you ship. Subscription tier changes are driven by the RevenueCat webhook, which is server-side. One-time and consumable purchases call the validate-purchase edge function after the SDK reports success, and that function records the purchase into transactions and user_products or the credits ledger, but it still carries a TODO to verify the receipt against RevenueCat's server API. Treat full server-side receipt verification as a launch checklist item rather than assuming it is already enforced for every purchase type.
GET IT BUILT INTO YOUR APP