
Expo Revenuecat Superwall Integration
- 49 installs
- 3 repo stars
- Updated June 29, 2026
- tristanmanchester/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
expo-revenuecat-superwall-integration is a Claude Code skill in the AI & Agent Building category.
- expo-revenuecat-superwall-integration
- AI & Agent Building
- AI-coding skill
Expo Revenuecat Superwall Integration by the numbers
- 49 all-time installs (skills.sh)
- +1 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #7,329 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tristanmanchester/agent-skills --skill expo-revenuecat-superwall-integrationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 49 |
|---|---|
| repo stars | ★ 3 |
| Last updated | June 29, 2026 |
| Repository | tristanmanchester/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Expo RevenueCat plus Superwall Integration
Use this skill to add or repair a modern RevenueCat plus Superwall stack in a React Native Expo app.
What this skill should do
- Choose the correct monetisation architecture before editing code.
- Integrate with the repository's existing app shell, auth layer, and state management.
- Prefer safe, production-ready defaults over the shortest possible demo.
- Leave the user with code changes plus a clear list of remaining dashboard, store, and testing steps.
Critical rules
- Treat this as an Expo development-build integration, not an Expo Go integration.
- Target Expo SDK 53 or newer.
- Target iOS deployment target 15.1 or newer and Android min SDK 23 or newer.
- Use public SDK keys only in the client.
- Configure RevenueCat exactly once.
- Mount Superwall near the app root exactly once.
- Use the same stable, non-guessable, non-PII user identifier in RevenueCat and Superwall when the product has authentication.
- Never use email addresses as RevenueCat or Superwall user IDs.
- Do not call
syncPurchases()on every launch. Use it only for deliberate migration or account-recovery scenarios. restorePurchases()is user-triggered. Do not hide it inside startup code.- On Android, ensure the launch mode is
standardorsingleTop. - Prefer a full app restart after Superwall dashboard changes during Expo development.
First actions
1. Inspect the repository before editing:
package.jsonapp.json,app.config.js, orapp.config.tsApp.tsxorapp/_layout.tsx- any existing auth provider
- any existing purchase, paywall, or entitlement code
2. Run the validator if Python is available:
python3 scripts/validate_expo_setup.py- or
python3 scripts/validate_expo_setup.py --project-root /path/to/app
3. Answer these six preflight questions before choosing code:
- Is the app login-first, login-optional, or guest-first
- Is there existing purchase completion logic already in the repo
- Does Google Play use multiple base plans or offers
- Are App Store Server Notifications, Google server notifications, webhooks, or backend attribution in scope
- Is the entitlement model single-tier or multi-tier
- Does the product need strict account ownership, or easy restore across account confusion
4. Open only the references you need:
- Core workflow:
references/implementation-playbook.md - Architecture choice:
references/architecture-decision-tree.md - Identity and restores:
references/identity-and-restore-behaviour.md - Android offers:
references/android-base-plans-offers-and-pending.md - iOS UUID and server notifications:
references/ios-uuid-appaccounttoken-and-server-notifications.md - Observability and verification:
references/observability-and-entitlement-verification.md - Test planning:
references/testing-matrix.md - Dashboard alignment:
references/dashboard-checklist.md - Failure modes:
references/troubleshooting.md
Architecture choice
Default for most new Expo apps
Choose Architecture A: CustomPurchaseControllerProvider when:
- Superwall is the paywall surface.
- RevenueCat is the purchase and entitlement source of truth.
- The app does not already have its own mature purchase completion pipeline.
- You want the cleanest modern Expo integration.
Use:
references/architecture-decision-tree.mdreferences/examples/monetization.shared.tsxreferences/examples/app.example.tsxreferences/examples/expo-router-layout.example.tsxreferences/examples/custom-purchase-controller.android-offers.tsx
Use the migration path when the repo already owns purchase completion
Choose Architecture B: purchasesAreCompletedBy / observer-mode migration when:
- The app already finishes transactions itself.
- The user explicitly wants to keep existing IAP code.
- You are layering RevenueCat analytics, entitlements, or dashboards onto an existing billing implementation.
- You must import historical purchases carefully.
Use:
references/architecture-decision-tree.mdreferences/examples/observer-mode-migration.tsxreferences/identity-and-restore-behaviour.md
Shared implementation workflow
1. Audit the repo
Collect these facts before changing code:
- Expo SDK version
- package manager
- router style: Expo Router or plain
App.tsx - whether
expo-superwall,react-native-purchases, andexpo-build-propertiesare already installed - current iOS deployment target and Android min SDK
- whether the app has auth
- whether the repo already has RevenueCat, Superwall, StoreKit, Google Play Billing, or
react-native-iapcode - whether the project already ships one-time products in addition to subscriptions
2. Align dashboards before deep code edits
Confirm the conceptual setup first:
- RevenueCat project exists for iOS and Android
- store products exist
- entitlements exist
- offerings exist where needed
- Superwall project exists
- Superwall public keys exist for both platforms
- placements and campaigns exist
- product IDs and entitlement IDs match the intended runtime mapping
Use references/dashboard-checklist.md.
3. Install only the packages you actually need
Base stack:
npx expo install expo-superwall react-native-purchases expo-build-properties
Optional only if the user explicitly wants RevenueCat UI screens such as a customer center:
npx expo install react-native-purchases-ui
Do not add react-native-purchases-ui just because RevenueCat is installed.
4. Update Expo config
Add or repair expo-build-properties and set platform minimums. Preserve the repository's config style and existing plugins.
5. Configure RevenueCat once
- Use the correct public key for the current platform.
- Configure once on startup.
- If the app always requires a known user ID, prefer configuring with that ID instead of creating an anonymous state first.
- If the app allows guests, configure without an App User ID and later call
logIn()when auth resolves.
6. Mount providers once near the root
For Architecture A, the normal order is:
1. configure RevenueCat 2. mount CustomPurchaseControllerProvider 3. mount SuperwallProvider 4. show SuperwallLoading 5. render the app inside SuperwallLoaded 6. mount one subscription sync component inside the loaded tree
7. Sync RevenueCat entitlements into Superwall
When Superwall is not directly owning purchase state, map RevenueCat entitlements into setSubscriptionStatus.
- Fetch
CustomerInfoon launch or when premium UI opens. - Subscribe to
addCustomerInfoUpdateListener. - Map active entitlement IDs into Superwall entitlements.
- Prefer syncing the full entitlement set, not just a boolean.
8. Sync identities deliberately
- Reuse the app's real auth state.
- For login-first apps, prefer configuring RevenueCat with a custom App User ID from the start.
- For guest-first apps, configure anonymously, then on login call
Purchases.logIn(userId)andidentify(userId). - If switching from one known account to another, call
logIn(newUserId)directly. Do not force a pointless logout first. - Only call
logOut()if the product truly supports an anonymous post-logout state.
See references/identity-and-restore-behaviour.md and references/examples/auth-sync.example.tsx.
9. Register placements from premium entry points
- Use business-action placement names such as
upgrade_pro,remove_limits, orexport_pdf. - Prefer placement-driven gating and dashboard audiences over hard-coded paywall branching.
- Use
getPresentationResult()only when you need to inspect what Superwall would do before presenting.
10. Add observability
- Forward Superwall events into the app's analytics pipeline.
- Keep debug logs enabled in development only.
- Consider checking RevenueCat trusted entitlement verification in high-risk apps.
See references/observability-and-entitlement-verification.md.
11. Test with a matrix, not one happy path
Always verify:
- cold start on iOS and Android
- purchase success
- cancel flow
- pending flow where relevant
- restore
- guest to logged-in transition
- account switch
- reinstall
- dashboard changes after a full restart
- entitlement unlock in UI
- webhook or server-notification identity consistency when relevant
Use references/testing-matrix.md.
What to avoid
- Do not use
expo-superwall/compatfor new work. - Do not promise real Superwall support inside Expo Go.
- Do not call
restorePurchases()automatically during startup. - Do not call
syncPurchases()on every launch. - Do not use emails, IDFA, device IDs, or hardcoded strings as billing IDs.
- Do not leave duplicate purchase flows in place.
- Do not ship Android API 21 just because Superwall alone supports it; the combined stack needs Android 23 or newer.
Final answer expectations
When you finish editing a repo, your response should include:
- the files changed
- the chosen architecture and why
- any manual RevenueCat, Superwall, App Store Connect, or Play Console steps still required
- any assumptions about entitlements, product IDs, placements, restore behaviour, or account model
- any migration debt intentionally left in place
- how to run and test the development build
MIT License
Copyright (c) 2026 OpenAI
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Android Base Plans, Offers, and Pending Purchases
Google Play subscriptions are often the place where a "working demo" breaks in production.
The problem
Superwall can tell your purchase callback more than just a productId. On Android it can also provide:
basePlanIdofferIdwhen present
If you ignore those and simply call purchaseStoreProduct(product), you may buy the wrong option or rely on RevenueCat's default offer selection when the product actually needs explicit control.
Recommended handling
1. Resolve the RevenueCat StoreProduct by productId. 2. Read storeProduct.subscriptionOptions. 3. Build the option identifier from basePlanId plus optional offerId. 4. Find the matching SubscriptionOption. 5. If an explicit match does not exist, decide whether falling back to defaultOption is acceptable. 6. Purchase the selected option using the method exposed by the installed react-native-purchases version. 7. Only report success to Superwall when the returned CustomerInfo has the expected active entitlement.
When fallback to defaultOption is acceptable
Fallback is usually reasonable when:
- the dashboard is meant to choose the default trial or intro offer
- there is no special developer-determined offer logic
- the team is comfortable with RevenueCat selecting the longest free trial or cheapest eligible intro option
Fallback is not ideal when:
- specific Play offers map to specific campaigns or experiments
- you use developer-determined offers
- the business wants exact option control
Pending purchases
Google Play can place a purchase into a pending state, especially with delayed payment methods or family approval.
Treat pending as its own result, not as a clean success.
Suggested behaviour:
- return
{ type: "pending" }if your integration surface supports it - otherwise return a clear failure-style message and explain that access will unlock after Google confirms payment
- keep entitlement checks in place because pending does not guarantee active access yet
Purchase success is not the same as entitlement success
Even after a purchase callback returns without throwing:
- inspect the
CustomerInfo - confirm the expected entitlement is active
- if not active, do not quietly mark the purchase as complete inside your app state
This catches configuration problems such as:
- product IDs exist but entitlements are not attached in RevenueCat
- Superwall and RevenueCat products are out of sync
- the wrong base plan or offer was selected
- a pending purchase has not completed yet
Developer-determined offer caveat
If the Play Console uses developer-determined offers, RevenueCat's automatic offer logic can still consider them unless you structure your offer setup carefully. For exact control, manually select the SubscriptionOption you intend to buy.
Implementation pointers
- see
examples/custom-purchase-controller.android-offers.tsx - see the helper functions in
examples/monetization.shared.tsx
Debug checklist
If Android purchases behave unexpectedly:
- verify the incoming
productId,basePlanId, andofferId - inspect the resolved RevenueCat
StoreProduct - log the available
subscriptionOptions - confirm the expected entitlement becomes active in
CustomerInfo - confirm the same product and entitlement setup exists in both RevenueCat and Superwall
- confirm the Android main activity launch mode is
standardorsingleTop
Architecture Decision Tree
This document tells the agent which implementation style to choose.
Start here
Answer these questions in order.
1. Does the repo already complete purchases itself?
If yes, strongly consider Architecture B:
- RevenueCat configured with
purchasesAreCompletedBy - existing purchase code remains in charge
- RevenueCat adds entitlement state, dashboards, analytics, and migration support
If no, continue.
2. Is Superwall the desired paywall surface?
If yes, strongly prefer Architecture A:
CustomPurchaseControllerProviderSuperwallProvider- RevenueCat as entitlement source of truth
- Superwall for placement, targeting, and paywall presentation
If no, this skill may not be the right fit. The user might instead want RevenueCat UI or another billing surface.
3. Is the app greenfield, or is it migrating an existing billing stack?
- Greenfield or light integration: Architecture A
- Migration, existing transaction completion, or phased rollout: Architecture B
4. Is the app login-first or guest-first?
This does not change the architecture by itself, but it changes identity handling:
- login-first: configure RevenueCat with a custom App User ID immediately when possible
- guest-first: configure anonymously, then
logIn()later - strict-account products: reconsider restore behaviour and avoid
logOut()if anonymous state is forbidden
5. Are App Store Server Notifications, Google server notifications, or backend attribution important?
If yes:
- identity design matters more than usual
- keep the same stable user ID across RevenueCat and Superwall
- prefer a UUID v4 style ID
- for iOS, understand
appAccountTokenfallback behaviour - for Android, understand whether you need
passIdentifiersToPlayStore
Read ios-uuid-appaccounttoken-and-server-notifications.md.
6. Does Google Play use multiple base plans or offers?
If yes, Architecture A still works, but the purchase callback must not just call purchaseStoreProduct(product) blindly. It must inspect basePlanId, optional offerId, and match a RevenueCat subscriptionOption.
Read android-base-plans-offers-and-pending.md.
Recommended defaults
Architecture A: CustomPurchaseControllerProvider
Choose this when:
- building a new Expo subscription stack
- Superwall is the paywall and campaign system
- RevenueCat is the billing source of truth
- the repo does not already own purchase completion
- the team wants the clearest Expo implementation
Key benefits:
- best fit for Superwall's Expo guidance
- modern React component model
- easiest place to route Android base plans and offers
- straightforward entitlement mirroring into Superwall
Main risks:
- forgetting to sync
CustomerInfointo Superwall - assuming Android offers work with a simple product purchase call
- not planning identity and restore behaviour early enough
Architecture B: purchasesAreCompletedBy / observer-mode migration
Choose this when:
- the repo already has purchase completion logic
- the team is migrating to RevenueCat without replacing everything immediately
- the project needs historical import behaviour via
syncPurchases() - changing store-facing billing logic right now would be risky
Key benefits:
- lowest migration risk
- RevenueCat can coexist with existing purchase code
- easier phased rollout in complex production apps
Main risks:
- calling
syncPurchases()too often - forgetting to set the iOS StoreKit version
- unclear ownership of restore flows
- duplicating or conflicting purchase completion paths
Escalate to the user only when truly necessary
Do not stop for small uncertainties. Make the best grounded choice and state the assumption.
Examples:
- If the repo already has
react-native-iapreceipt handling, default to Architecture B. - If there is no purchase code and the user explicitly wants Superwall paywalls, default to Architecture A.
- If the repo is authenticated and the team mentions webhooks or server notifications, assume identity strategy is a top-priority design constraint.
Dashboard and Console Checklist
Use this before deep code work and again before release.
RevenueCat
- project created
- iOS app added
- Android app added
- public SDK keys copied for both platforms
- store products imported or created
- products attached to the correct entitlement or entitlements
- offerings created if the app uses them
- restore behaviour chosen intentionally
- any one-time products reviewed for restore implications
- server notifications or webhook settings reviewed if backend attribution matters
- app user ID policy documented for the team
Superwall
- project created
- iOS public key copied
- Android public key copied
- placements created for the premium entry points
- paywalls and campaigns active
- products configured
- entitlements configured and matching the intended runtime mapping
- audience filters reviewed
- user attributes plan reviewed if segmentation matters
- event export or analytics plan reviewed
App Store Connect / Google Play Console
- bundle IDs and package names match the app
- subscription groups or base plans created
- offers configured intentionally
- test accounts prepared
- internal testing track or TestFlight path ready
- business wants exact Google Play offer control or is happy with defaults
Alignment rules between systems
The riskiest bugs usually come from mismatch.
Check:
- product IDs match store reality
- entitlement IDs are consistent with app feature names
- Superwall and RevenueCat both know about the relevant products
- the app knows which entitlement IDs should unlock which features
- if multi-tier, the app understands more than one active entitlement
Identity policy checklist
- guest-first, login-optional, or login-required is documented
- RevenueCat App User ID format is documented
- Superwall user ID format is documented
- iOS UUID requirement is understood when server notifications matter
- Android Play identifier passthrough policy is documented
import { SafeAreaView, Text, View } from "react-native";
import { MonetizationProviders } from "./monetization.shared";
function RootScreen() {
return (
<SafeAreaView style={{ flex: 1 }}>
<View style={{ flex: 1, padding: 24, justifyContent: "center" }}>
<Text style={{ fontSize: 22, fontWeight: "600" }}>
Replace this screen tree with your real navigator or app content.
</Text>
<Text style={{ marginTop: 12, lineHeight: 22 }}>
Keep monetization providers mounted once at the app root.
</Text>
</View>
</SafeAreaView>
);
}
export default function App() {
return (
<MonetizationProviders>
<RootScreen />
</MonetizationProviders>
);
}
import { useEffect, useRef } from "react";
import Purchases from "react-native-purchases";
import { useUser } from "expo-superwall";
type AuthIdentitySyncProps = {
/**
* Set to true once the app knows whether a user session exists.
*/
isAuthResolved: boolean;
/**
* The stable billing user ID. Prefer a UUID or another opaque backend ID.
*/
userId: string | null;
/**
* True only if the product genuinely supports guest mode after sign out.
* If false, the hook avoids `Purchases.logOut()` so the SDK never creates
* a fresh anonymous user during account switching.
*/
allowAnonymousState: boolean;
};
export function AuthIdentitySync({
isAuthResolved,
userId,
allowAnonymousState,
}: AuthIdentitySyncProps) {
const { identify, signOut } = useUser();
const lastAppliedUserId = useRef<string | null | undefined>(undefined);
useEffect(() => {
if (!isAuthResolved) {
return;
}
if (lastAppliedUserId.current === userId) {
return;
}
let cancelled = false;
const run = async () => {
try {
if (userId) {
await Purchases.logIn(userId);
if (!cancelled) {
await identify(userId);
}
} else if (allowAnonymousState) {
await Purchases.logOut();
if (!cancelled) {
await signOut();
}
} else {
/**
* Login-required or custom-ID-only products should not create a fresh
* anonymous RevenueCat user on logout. Let the app remain in a signed-out
* app state and wait until the next real user logs in.
*/
}
lastAppliedUserId.current = userId;
} catch (error) {
console.error("Failed to sync billing identity:", error);
}
};
void run();
return () => {
cancelled = true;
};
}, [allowAnonymousState, identify, isAuthResolved, signOut, userId]);
return null;
}
import Purchases from "react-native-purchases";
type OnPurchaseParams = {
productId: string;
basePlanId?: string;
offerId?: string;
};
function getPlayOptionId(basePlanId?: string, offerId?: string) {
return [basePlanId, offerId].filter(Boolean).join(":");
}
function resolvePlaySubscriptionOption(
storeProduct: any,
basePlanId?: string,
offerId?: string,
) {
const optionId = getPlayOptionId(basePlanId, offerId);
const options: any[] = Array.isArray(storeProduct?.subscriptionOptions)
? storeProduct.subscriptionOptions
: [];
if (optionId) {
const explicit = options.find((option) => option?.id === optionId);
if (explicit) {
return explicit;
}
}
return storeProduct?.defaultOption ?? options[0] ?? null;
}
export async function purchaseFromSuperwallParams({
productId,
basePlanId,
offerId,
}: OnPurchaseParams) {
const [storeProduct] = await Purchases.getProducts([productId]);
if (!storeProduct) {
throw new Error(`No RevenueCat product found for ${productId}`);
}
const option = resolvePlaySubscriptionOption(
storeProduct,
basePlanId,
offerId,
);
if (!option) {
throw new Error(
`No Google Play subscription option matched ${getPlayOptionId(
basePlanId,
offerId,
) || "the default selection"}`,
);
}
/**
* Adapt this call to the exact purchase helper exposed by the installed
* `react-native-purchases` version.
*/
const purchasesModule: any = Purchases as any;
if (typeof purchasesModule.purchaseSubscriptionOption === "function") {
return await purchasesModule.purchaseSubscriptionOption(option);
}
if (typeof purchasesModule.purchase === "function") {
return await purchasesModule.purchase({
storeProduct,
subscriptionOption: option,
});
}
return await purchasesModule.purchaseStoreProduct(storeProduct);
}
import { Stack } from "expo-router";
import { MonetizationProviders } from "./monetization.shared";
export default function RootLayout() {
return (
<MonetizationProviders>
<Stack screenOptions={{ headerShown: false }} />
</MonetizationProviders>
);
}
import { ReactNode, useEffect, useMemo } from "react";
import { ActivityIndicator, Platform, Text, View } from "react-native";
import Purchases, {
LOG_LEVEL,
PURCHASES_ERROR_CODE,
type CustomerInfo,
} from "react-native-purchases";
import {
CustomPurchaseControllerProvider,
SuperwallLoaded,
SuperwallLoading,
SuperwallProvider,
useSuperwallEvents,
useUser,
} from "expo-superwall";
const revenueCatApiKeys = {
ios: process.env.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY ?? "",
android: process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY ?? "",
} as const;
const superwallApiKeys = {
ios: process.env.EXPO_PUBLIC_SUPERWALL_IOS_API_KEY ?? "",
android: process.env.EXPO_PUBLIC_SUPERWALL_ANDROID_API_KEY ?? "",
} as const;
const expectedEntitlementIds = ["pro", "premium"];
let purchasesConfigured = false;
function getPlatformKey(keys: { ios: string; android: string }) {
return Platform.OS === "ios" ? keys.ios : keys.android;
}
function extractCustomerInfo(result: any): CustomerInfo {
return result?.customerInfo ?? result;
}
function hasExpectedEntitlement(
customerInfo: CustomerInfo,
entitlementIds = expectedEntitlementIds,
) {
return entitlementIds.some(
(entitlementId) => customerInfo.entitlements.active[entitlementId],
);
}
function isCancelledError(error: unknown) {
const code = (error as any)?.code;
return code === PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR;
}
function isPendingError(error: unknown) {
const code = (error as any)?.code;
return (
code === (PURCHASES_ERROR_CODE as any).PAYMENT_PENDING_ERROR ||
code === "PAYMENT_PENDING_ERROR" ||
code === "paymentPendingError" ||
/pending/i.test(String((error as any)?.message ?? ""))
);
}
async function getStoreProduct(productId: string) {
const products = await Purchases.getProducts([productId]);
const product = products[0];
if (!product) {
throw new Error(`RevenueCat product not found for ${productId}`);
}
return product;
}
function getAndroidOptionId(basePlanId?: string, offerId?: string) {
return [basePlanId, offerId].filter(Boolean).join(":");
}
function resolveAndroidSubscriptionOption(
storeProduct: any,
basePlanId?: string,
offerId?: string,
) {
const explicitOptionId = getAndroidOptionId(basePlanId, offerId);
const subscriptionOptions: any[] = Array.isArray(storeProduct?.subscriptionOptions)
? storeProduct.subscriptionOptions
: [];
if (explicitOptionId) {
const matchedOption = subscriptionOptions.find(
(option) => option?.id === explicitOptionId,
);
if (matchedOption) {
return matchedOption;
}
}
return storeProduct?.defaultOption ?? subscriptionOptions[0] ?? null;
}
/**
* RevenueCat's exact purchase API for subscription options can vary a little
* across SDK generations. This helper keeps the example adaptable:
* - prefer an explicit subscription-option purchase method when present
* - otherwise try a generic purchase call
* - fall back to `purchaseStoreProduct` as a last resort
*
* Adapt this helper to the exact API surface exposed by the installed
* `react-native-purchases` version in the user's repository.
*/
async function purchaseAndroidSubscriptionOption(
storeProduct: any,
basePlanId?: string,
offerId?: string,
): Promise<CustomerInfo> {
const option = resolveAndroidSubscriptionOption(storeProduct, basePlanId, offerId);
if (!option) {
throw new Error(
`Could not resolve a Google Play subscription option for product ${storeProduct?.identifier ?? "unknown"}.`,
);
}
const purchasesModule: any = Purchases as any;
if (typeof purchasesModule.purchaseSubscriptionOption === "function") {
const result = await purchasesModule.purchaseSubscriptionOption(option);
return result?.customerInfo ?? result;
}
if (typeof purchasesModule.purchase === "function") {
const result = await purchasesModule.purchase({
storeProduct,
subscriptionOption: option,
});
return result?.customerInfo ?? result;
}
const result = await purchasesModule.purchaseStoreProduct(storeProduct);
return extractCustomerInfo(result);
}
function MonetizationBootstrap() {
useEffect(() => {
if (purchasesConfigured) {
return;
}
const apiKey = getPlatformKey(revenueCatApiKeys);
if (!apiKey) {
console.warn("Missing RevenueCat public API key for this platform.");
return;
}
if (__DEV__) {
Purchases.setLogLevel(LOG_LEVEL.VERBOSE);
}
Purchases.configure({ apiKey });
purchasesConfigured = true;
}, []);
return null;
}
function SubscriptionSync() {
const { setSubscriptionStatus } = useUser();
useEffect(() => {
let mounted = true;
const applyCustomerInfo = async (customerInfo: CustomerInfo) => {
if (!mounted) {
return;
}
const entitlementIds = Object.keys(customerInfo.entitlements.active);
await setSubscriptionStatus({
status: entitlementIds.length > 0 ? "ACTIVE" : "INACTIVE",
entitlements: entitlementIds.map((id) => ({
id,
type: "SERVICE_LEVEL",
})),
});
};
const listener = Purchases.addCustomerInfoUpdateListener((customerInfo) => {
void applyCustomerInfo(customerInfo);
});
void Purchases.getCustomerInfo()
.then((customerInfo) => applyCustomerInfo(customerInfo))
.catch((error) => {
console.warn("Initial RevenueCat subscription sync failed:", error);
});
return () => {
mounted = false;
listener?.remove();
};
}, [setSubscriptionStatus]);
return null;
}
function AnalyticsBridge() {
useSuperwallEvents({
onPaywallPresent: (paywallInfo) => {
console.log("Superwall paywall presented", paywallInfo);
},
onPaywallDismiss: (paywallInfo, result) => {
console.log("Superwall paywall dismissed", { paywallInfo, result });
},
onSubscriptionStatusChange: (status) => {
console.log("Superwall subscription status changed", status);
},
onPurchase: (params) => {
console.log("Superwall purchase started", params);
},
onPurchaseRestore: () => {
console.log("Superwall restore started");
},
onPaywallError: (error) => {
console.warn("Superwall paywall error", error);
},
});
return null;
}
function LoadingState() {
return (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: 24,
}}
>
<ActivityIndicator />
<Text style={{ marginTop: 12 }}>Loading subscriptions…</Text>
</View>
);
}
type MonetizationProvidersProps = {
children: ReactNode;
};
export function MonetizationProviders({
children,
}: MonetizationProvidersProps) {
const controller = useMemo(
() => ({
onPurchase: async ({
productId,
basePlanId,
offerId,
}: {
productId: string;
basePlanId?: string;
offerId?: string;
}) => {
try {
const storeProduct = await getStoreProduct(productId);
const customerInfo =
Platform.OS === "android" && (basePlanId || offerId)
? await purchaseAndroidSubscriptionOption(
storeProduct,
basePlanId,
offerId,
)
: extractCustomerInfo(await Purchases.purchaseStoreProduct(storeProduct));
if (!hasExpectedEntitlement(customerInfo)) {
return {
type: "failed",
error:
"Purchase completed, but the expected entitlement is still inactive. Check RevenueCat and Superwall product and entitlement mappings.",
} as const;
}
return { type: "purchased" } as const;
} catch (error) {
if (isCancelledError(error)) {
return { type: "cancelled" } as const;
}
if (isPendingError(error)) {
return { type: "pending" } as const;
}
return {
type: "failed",
error: (error as any)?.message ?? "Purchase failed",
} as const;
}
},
onPurchaseRestore: async () => {
try {
const customerInfo = extractCustomerInfo(await Purchases.restorePurchases());
if (!hasExpectedEntitlement(customerInfo)) {
return {
type: "failed",
error:
"Restore completed, but no expected entitlement became active.",
} as const;
}
return { type: "restored" } as const;
} catch (error) {
return {
type: "failed",
error: (error as any)?.message ?? "Restore failed",
} as const;
}
},
}),
[],
);
return (
<>
<MonetizationBootstrap />
<CustomPurchaseControllerProvider controller={controller}>
<SuperwallProvider apiKeys={superwallApiKeys}>
<SuperwallLoading>
<LoadingState />
</SuperwallLoading>
<SuperwallLoaded>
<SubscriptionSync />
<AnalyticsBridge />
{children}
</SuperwallLoaded>
</SuperwallProvider>
</CustomPurchaseControllerProvider>
</>
);
}
import { useEffect } from "react";
import { Platform } from "react-native";
import Purchases, {
LOG_LEVEL,
PURCHASES_ARE_COMPLETED_BY_TYPE,
STOREKIT_VERSION,
} from "react-native-purchases";
const revenueCatApiKeys = {
ios: process.env.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY ?? "",
android: process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY ?? "",
} as const;
let purchasesConfigured = false;
function getPlatformKey(keys: { ios: string; android: string }) {
return Platform.OS === "ios" ? keys.ios : keys.android;
}
type RevenueCatObserverModeBootstrapProps = {
appUserId: string | null;
shouldSyncHistoricalPurchases: boolean;
};
export function RevenueCatObserverModeBootstrap({
appUserId,
shouldSyncHistoricalPurchases,
}: RevenueCatObserverModeBootstrapProps) {
useEffect(() => {
const apiKey = getPlatformKey(revenueCatApiKeys);
if (!apiKey || purchasesConfigured) {
return;
}
if (__DEV__) {
Purchases.setLogLevel(LOG_LEVEL.DEBUG);
}
/**
* Use this only when the app already owns purchase completion.
* Older docs may still call this observer mode.
*/
Purchases.configure({
apiKey,
purchasesAreCompletedBy: {
type: PURCHASES_ARE_COMPLETED_BY_TYPE.MY_APP,
...(Platform.OS === "ios"
? { storeKitVersion: STOREKIT_VERSION.STOREKIT_2 }
: null),
},
...(appUserId ? { appUserID: appUserId } : null),
});
purchasesConfigured = true;
}, [appUserId]);
useEffect(() => {
if (!purchasesConfigured || !appUserId) {
return;
}
let cancelled = false;
const run = async () => {
try {
await Purchases.logIn(appUserId);
/**
* Sync historical purchases only at deliberate checkpoints,
* usually after login or as part of a migration step.
*/
if (shouldSyncHistoricalPurchases) {
await Purchases.syncPurchases();
}
} catch (error) {
if (!cancelled) {
console.warn("RevenueCat observer-mode login or historical sync failed:", error);
}
}
};
void run();
return () => {
cancelled = true;
};
}, [appUserId, shouldSyncHistoricalPurchases]);
return null;
}
import { Button, Text, View } from "react-native";
import { usePlacement, useUser } from "expo-superwall";
function hasEntitlement(
subscriptionStatus: ReturnType<typeof useUser>["subscriptionStatus"],
entitlementId: string,
) {
return (
subscriptionStatus?.entitlements?.some(
(entitlement) => entitlement.id === entitlementId,
) ?? false
);
}
export function ExportPdfUpsell() {
const { registerPlacement, state } = usePlacement({
onPresent: (info) => console.log("Paywall presented", info),
onDismiss: (info, result) => console.log("Paywall dismissed", { info, result }),
onError: (error) => console.warn("Paywall placement error", error),
});
const { subscriptionStatus } = useUser();
const isPro = hasEntitlement(subscriptionStatus, "pro");
return (
<View style={{ gap: 12 }}>
<Text style={{ lineHeight: 22 }}>
Prefer registering the placement and letting Superwall's dashboard logic
decide whether the user should actually see a paywall.
</Text>
<Button
title={isPro ? "Export PDF" : "Unlock PDF export"}
onPress={() => {
void registerPlacement({
placement: "export_pdf",
params: {
source: "editor_toolbar",
entitlementHint: isPro ? "already_pro" : "not_pro",
},
});
}}
/>
{state ? <Text selectable>{JSON.stringify(state, null, 2)}</Text> : null}
</View>
);
}
Identity and Restore Behaviour
This is the highest-risk part of a RevenueCat plus Superwall integration.
Core identity rules
Use the same stable business identity in both systems whenever the product has authentication.
Good identifiers:
- UUID v4
- stable internal opaque IDs
- non-guessable backend-generated IDs
Bad identifiers:
- email addresses
- IDFA or device IDs
- hardcoded strings
- placeholder strings like
guest,unknown,0, ornull
If the app already has numeric or human-readable legacy IDs, strongly consider adding a backend UUID mapping layer rather than sending raw legacy IDs everywhere.
RevenueCat identity model
Guest-first products
Recommended flow:
1. configure RevenueCat without appUserID 2. let RevenueCat create an anonymous App User ID 3. when auth resolves, call Purchases.logIn(userId)
Use this when the product allows purchasing before account creation.
Login-first products
Recommended flow:
1. resolve auth before configuring billing 2. configure RevenueCat with a known appUserID 3. do not call logOut() if the product never allows anonymous state
Use this when every purchase must belong to a real account.
Switching from one logged-in account to another
Use:
await Purchases.logIn(nextUserId);Do not force a logOut() first. That just creates a temporary anonymous ID and adds unnecessary aliasing risk.
Logging out
Only call:
await Purchases.logOut();when the product genuinely supports a guest state after logout.
If the product is custom-ID-only, do not log the SDK out. Wait until the next user logs in and then call logIn(nextUserId).
Superwall identity model
When auth resolves:
await identify(userId);When signing out to guest state:
await signOut();If users frequently reinstall or switch accounts and correct paywall assignments matter immediately, consider the restorePaywallAssignments option on identify. Use it deliberately because it changes paywall startup behaviour.
Restore behaviour in RevenueCat
RevenueCat restore behaviour is a dashboard-level policy. Pick it intentionally.
Default and usually recommended: Transfer to new App User ID
Use when:
- the app is guest-first
- login is optional
- easy recovery matters more than strict account ownership
- the team wants the least painful restore experience
Keep with original App User ID
Use only when:
- every purchaser must have an account
- purchases must never transfer across accounts
- the support team can recover lost account ownership safely
This is stricter and can create support burden.
Important nuance with anonymous users
Anonymous users can be aliased with a known App User ID later. That can be good for guest-first products, but confusing for strict-account products.
Restores versus sync
restorePurchases()
Use only from an explicit user action such as a Restore Purchases button.
Reason:
- it can trigger store account prompts
- it is the user-facing restore path
syncPurchases()
Use only for intentional cases such as:
- importing historical purchases during migration
- syncing past subscriptions after login in observer mode
- specific recovery flows
Do not run it on every launch.
Product-shape recommendations
Login required before purchase
- configure RevenueCat with custom App User ID from the start
- identify the same ID in Superwall
- avoid anonymous sessions
- consider whether
Keep with original App User IDis worth the support cost
Login optional
- allow anonymous state
logIn()when auth appears- keep default transfer behaviour unless the business has a strong reason not to
Guest-first purchase flow
- anonymous configuration is normal
- users will later alias into known accounts
- make restore UX clear inside settings
- support staff should know how aliases and restores behave
Support and debugging advice
- show the current App User ID in app settings
- keep both RevenueCat and Superwall user IDs visible in debug builds if possible
- note the chosen restore-behaviour policy in the project README or team docs
- if you rely on webhooks or server notifications, verify that the ID observed on the backend matches the intended business user
Implementation Playbook
This is the main workflow for integrating RevenueCat plus Superwall into a React Native Expo app.
1. Preflight audit
Before editing code, answer:
1. Is the app using Expo Router or a plain App.tsx root? 2. Is auth required before purchase, optional, or absent? 3. Is there already purchase code in the repo? 4. Does the product have more than one entitlement tier? 5. Does Google Play use multiple base plans or offers? 6. Are webhooks, App Store Server Notifications, or backend attribution part of the project?
Then inspect:
package.jsonapp.jsonorapp.config.*App.tsxorapp/_layout.tsx- auth provider files
- existing monetisation files
- environment handling
2. Choose the architecture
Architecture A: Superwall paywalls plus RevenueCat entitlements via CustomPurchaseControllerProvider
Use this for most new Expo work.
Choose it when:
- Superwall is the paywall UI
- RevenueCat is the billing and entitlement source of truth
- the app does not already own transaction completion
- you want the cleanest modern Expo integration
Architecture B: RevenueCat with purchasesAreCompletedBy and existing IAP code
Use this when:
- the repo already owns purchase completion logic
- the team is migrating an existing billing stack
- the team needs RevenueCat dashboards and entitlements without rewriting all purchase code at once
- you must import historical subscriptions carefully
Read architecture-decision-tree.md before choosing.
3. Install packages
Base install:
npx expo install expo-superwall react-native-purchases expo-build-propertiesOptional RevenueCat UI only when explicitly requested:
npx expo install react-native-purchases-uiDo not add the UI package if Superwall is the only paywall and management surface.
4. Update Expo config
Set minimum platform versions via expo-build-properties.
Example app.config.ts fragment:
export default {
expo: {
plugins: [
[
"expo-build-properties",
{
ios: {
deploymentTarget: "15.1",
},
android: {
minSdkVersion: 23,
},
},
],
],
},
};Also confirm:
- bundle identifiers and package names already exist
- EAS or native build pipeline is configured
- Android main activity launch mode is
standardorsingleTop
5. Add public environment variables
Typical keys:
EXPO_PUBLIC_REVENUECAT_IOS_API_KEY=appl_xxx
EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY=goog_xxx
EXPO_PUBLIC_SUPERWALL_IOS_API_KEY=sw_ios_xxx
EXPO_PUBLIC_SUPERWALL_ANDROID_API_KEY=sw_android_xxxOptional:
EXPO_PUBLIC_DEFAULT_PLACEMENT=upgrade_proNever put RevenueCat secret keys or webhook secrets in the client.
6. Implement the root providers
Plain Expo app
Use references/examples/app.example.tsx.
Expo Router app
Use references/examples/expo-router-layout.example.tsx.
Normal root shape for Architecture A:
1. configure RevenueCat once 2. mount CustomPurchaseControllerProvider 3. mount SuperwallProvider 4. render SuperwallLoading 5. render the app inside SuperwallLoaded 6. mount one entitlement sync component inside the loaded tree 7. optionally mount one analytics bridge
Why this order
- RevenueCat should be ready before purchase callbacks run.
- Superwall should load before placements are used.
- Subscription sync should run only once and as close to the root as practical.
7. Configure RevenueCat deliberately
Guest-first apps
- configure without
appUserID - later call
Purchases.logIn(userId)on auth success - on logout, call
logOut()only if the app genuinely supports anonymous state
Login-first apps
- configure with the custom App User ID from the start
- avoid generating anonymous IDs at all
- do not call
logOut()if every app session must always belong to a known account
Production advice
- enable verbose or debug logging only in development
- reveal the App User ID somewhere in app settings for support
- use a UUID v4 or another non-guessable stable identifier
- never use an email address
8. Implement Architecture A purchase handling
Inside CustomPurchaseControllerProvider:
- resolve the requested RevenueCat
StoreProductusingproductId - for Android subscriptions, use
basePlanIdand optionalofferIdto pick a matchingsubscriptionOption - fall back to the product's default option only when appropriate
- call
purchaseStoreProducton iOS or when there is no special Android option selection - return
cancelled,pending, orfailedexplicitly when needed - after purchase, check the resulting
CustomerInfo - only treat the purchase as successful when the expected entitlement state is active
See:
examples/monetization.shared.tsxexamples/custom-purchase-controller.android-offers.tsxandroid-base-plans-offers-and-pending.md
9. Implement Architecture B migration path
Use this only when the repo already owns purchase completion.
- configure RevenueCat with
purchasesAreCompletedBy - on iOS, set the StoreKit version to match the app
- keep the existing purchase completion logic
- call
syncPurchases()after login or migration checkpoints, not every launch - audit restore behaviour carefully before shipping
See examples/observer-mode-migration.tsx.
10. Sync entitlements into Superwall
This step is easy to miss and causes "purchase succeeded but app is still locked" bugs.
Recommended pattern:
- call
Purchases.getCustomerInfo()on launch and when premium areas open - add
Purchases.addCustomerInfoUpdateListener(...) - map
customerInfo.entitlements.activekeys into Superwall entitlements - pass
ACTIVE,INACTIVE, orUNKNOWNas appropriate
Prefer syncing the full entitlement set instead of just isSubscribed.
11. Sync identities across both systems
When auth resolves:
- RevenueCat:
logIn(userId)or configure withappUserID - Superwall:
identify(userId)
When switching known account A to known account B:
- call
logIn(nextUserId)directly - call
identify(nextUserId)directly - do not force a pointless
logOut()first
When signing out to guest state:
- RevenueCat:
logOut()only if guest mode is real and desired - Superwall:
signOut()
When account switching or reinstall-heavy usage makes paywall assignment restoration important, consider Superwall's restorePaywallAssignments identity option. Use it intentionally, not by default.
12. Register placements
Examples:
upgrade_proexport_pdfvoice_cloneremove_generation_limit
Recommendations:
- use one placement per business action
- keep names dashboard-friendly
- do not pre-emptively block every call with manual entitlement checks
- let Superwall audiences and entitlements decide whether to show a paywall when possible
- use
getPresentationResult()when you need to inspect what would happen before presenting
See examples/premium-gate.example.tsx.
13. Add observability
Implement at least one of these:
- Superwall event forwarding via
useSuperwallEvents - analytics forwarding for paywall shown, purchase started, purchase result, restore started, restore result
- RevenueCat debug logging in development
- optional entitlement verification checks for high-risk products
14. Test with a real matrix
Use testing-matrix.md. Minimum cases:
- iOS purchase success
- Android purchase success
- cancel flow
- pending flow
- restore flow
- guest-to-authenticated upgrade
- authenticated purchase on a second account
- reinstall and restore
- full restart after dashboard changes
- webhook or server-notification identity checks when applicable
15. Final delivery checklist
When editing the user's repo, finish with:
- changed file list
- chosen architecture and why
- manual dashboard and console work still required
- assumptions and open questions
- exact run command for the development build
- test steps that cover the riskiest path first
iOS UUID, appAccountToken, and Server Notifications
This document matters whenever the project cares about backend attribution, webhooks, or server notifications.
Why this exists
On iOS, Superwall supplies an appAccountToken with StoreKit 2 transactions.
That sounds great, but there is a crucial nuance:
- if you call
identify(userId)with a valid UUID, that UUID can flow through as theappAccountToken - if you have not identified yet, Superwall uses the anonymous alias UUID
- if you pass a non-UUID user ID, StoreKit rejects it and Superwall falls back to the alias UUID
This means the backend-observed identifier can differ from the app-level ID unless identity is designed carefully.
Strong recommendation
If the project uses any of the following:
- App Store Server Notifications
- RevenueCat server-side purchase tracking
- custom attribution
- backend entitlement reconciliation
- webhook-driven account linking
then use the same stable UUID v4 for:
- the app's billing identity
- RevenueCat App User ID
- Superwall identify ID
If the existing backend uses some other primary key, add a UUID mapping layer.
RevenueCat interaction
RevenueCat warns that when using server-to-server tracking together with appAccountToken or obfuscatedExternalAccountId, those values should match the RevenueCat App User ID and that ID should be a valid UUID v4.
This is one of the most important production details in the whole integration.
What to do in practice
Login-first app
- generate or fetch the user's stable UUID before billing initialisation
- configure RevenueCat with that App User ID
- call Superwall
identify()with the same UUID before the user purchases
Guest-first app
- accept that early anonymous transactions may carry an alias UUID
- when the user later logs in, aliasing can still work, but historical server-side attribution may point at the earlier anonymous identifier
- if this is unacceptable, redesign the product flow so billing only begins after a known UUID exists
Android note
If the business needs the Play-side identifier to map cleanly back to the app user, review whether Superwall should pass identifiers through to Google Play on Android. This is useful, but only if the identifier policy is compatible with exposing that stable opaque ID.
Webhook troubleshooting checklist
If webhook or server-notification identities look wrong:
- confirm
identify()runs before any purchase - confirm the identifier is a UUID
- confirm the same value is used in RevenueCat and Superwall
- confirm Android identifier passthrough settings if testing Google Play
- update old SDK versions before debugging historical mismatches
- remember that historical purchases made before the identity fix may remain attached to the old alias identity
Suggested team policy
Write this into the project docs:
- the canonical billing user ID format
- whether the app allows anonymous purchases
- whether server notifications are enabled
- whether Android Play-side identifier passthrough is enabled
- what support should do when a webhook points at an older alias ID
Observability and Entitlement Verification
A production integration should be observable, not just compilable.
Minimum observability
Add these in development at least:
- RevenueCat debug or verbose logging
- Superwall placement callbacks or
useSuperwallEvents - logs for current billing identity after auth changes
- logs for active entitlements after
CustomerInfoupdates
Forward Superwall events to analytics
Useful events to forward:
- paywall will present
- paywall did present
- paywall dismissed
- paywall skipped
- purchase started
- purchase result
- restore started
- restore result
- subscription status changed
- custom paywall action tapped
These events are valuable for:
- funnel analysis
- experiment validation
- debugging
- support
Suggested analytics payload shape
Keep a consistent envelope:
type BillingAnalyticsEvent = {
name: string;
appUserId?: string | null;
superwallUserId?: string | null;
placement?: string;
paywallId?: string;
productId?: string;
entitlementIds?: string[];
result?: "presented" | "dismissed" | "purchased" | "restored" | "failed" | "cancelled" | "pending";
platform: "ios" | "android";
};RevenueCat trusted entitlements
Trusted Entitlements can provide integrity verification data for entitlement responses.
Important nuance:
- newer RevenueCat SDKs provide verification data by default
- the result is informational only unless the app actually checks it
- RevenueCat does not automatically block unverified entitlements for you
Use this when the app has a strong fraud or tampering risk model.
Sensible default policy
For most apps:
- log the verification result in debug builds
- keep access decisions based on normal entitlement state
- escalate to stricter handling only if the business has a clear need
For high-risk apps:
- inspect the verification result
- decide whether unverified entitlements should reduce access or trigger review
- coordinate this with backend policy, not just the mobile app
Other useful signals
Also log:
- current App User ID
- current Superwall identified user
- active offering and product IDs during test purchases
- current restore-behaviour policy in team docs
- launch mode on Android when debugging odd cancellation paths
Example wiring
See examples/monetization.shared.tsx for a simple event bridge shape and examples/premium-gate.example.tsx for placement-level hooks.
Source Notes for Maintainers
This skill was revised as v2 on 2026-03-08.
Key documentation reviewed while preparing this version:
Agent Skills resources
- agentskills.io home and what-are-skills pages
- agentskills.io specification
- BEST_PRACTICES_FOR_WRITING_AND_USING_SKILLS_MD_FILES.md
- the bundled PDF guide about building skills
Key skill-specific takeaways applied here:
- keep
SKILL.mdfocused - push detailed material into
references/ - make the description explicit about what and when
- keep the folder in kebab-case and include
SKILL.mdexactly - include examples, references, validation help, and a zipped deliverable
Superwall resources
Reviewed areas included:
- Expo install and development-build requirements
- RevenueCat integration guide
- subscription-state tracking
- user management
- webhook and original-app-user-id troubleshooting
- placement troubleshooting
- analytics and
useSuperwallEvents CustomPurchaseControllerProviderdocs
RevenueCat resources
Reviewed areas included:
- Expo and React Native installation
- identifying customers
- restoring purchases
- restore behaviour
- trusted entitlements
- observer mode /
purchasesAreCompletedBy - Test Store and sandbox guidance
- Billing Client 8 restore issue notes
Intent of v2
The main improvement over v1 is that the skill is now decision-led rather than happy-path-only. It explicitly teaches the agent to reason about:
- architecture choice
- identity model
- restore policy
- Android base plans and offers
- iOS UUID and server-notification consequences
- observability
- test coverage
Testing Matrix
Do not ship based on one successful sandbox purchase.
Build setup checks
Before purchase testing:
- development build works on iOS and Android
- public SDK keys load correctly per platform
- app compiles with the chosen architecture
- at least one placement is active in Superwall
- products and entitlements exist in RevenueCat
- test users are ready in sandbox or test store as needed
Recommended environments
Early code validation
Use:
- development builds
- RevenueCat Test Store when appropriate
- mocked or internal analytics
Pre-release validation
Use:
- iOS sandbox or TestFlight test accounts
- Google Play internal testing or closed testing
- real purchase flows through store sandboxes
- backend logging if server notifications or webhooks matter
Scenario matrix
A. App startup
1. cold start without auth 2. cold start with known authenticated user 3. cold start after reinstall 4. full restart after changing Superwall placements or campaigns
Expected results:
- providers initialise once
- no duplicate purchase listeners
- no placement-not-found surprises after a restart
- entitlement sync runs once
B. Purchase flows
1. successful purchase on iOS 2. successful purchase on Android 3. user-cancelled purchase 4. network or store failure 5. pending Android purchase 6. purchase completes but entitlement is misconfigured
Expected results:
- success unlocks the correct entitlement
- cancellation is not shown as success
- pending does not falsely unlock premium access
- misconfigured entitlements are visible in logs and UI state
C. Restore flows
1. explicit Restore Purchases button tapped by a guest user 2. restore on a signed-in user 3. restore after reinstall 4. restore when transfer behaviour matters across two accounts
Expected results:
- restore is user-triggered
- entitlement state updates correctly
- account ownership behaviour matches the chosen RevenueCat restore policy
D. Identity transitions
1. guest uses app, then logs in 2. user A signs out to guest state 3. user A switches directly to user B 4. login-first app launches with custom ID from the start
Expected results:
- same ID is used in RevenueCat and Superwall
- direct account switch does not create unnecessary anonymous IDs
- support can inspect the active billing identity
E. Webhook and attribution checks
Only for projects that need them.
1. make a fresh purchase after identify() 2. inspect backend webhook or server-notification payload 3. compare app identity, RevenueCat App User ID, and server-observed identifier 4. repeat with Android if Play-side identifier passthrough matters
Expected results:
- backend identifiers match the intended user model
- UUID expectations are met on iOS
- historical purchases made before the identity fix are understood and documented
Release gate
Before sign-off, the project should be able to answer:
- which architecture is in use and why
- whether guest purchases are allowed
- which restore behaviour is configured in RevenueCat
- whether webhooks and server notifications rely on UUID billing IDs
- which entitlements unlock which features
- how support staff can find a user's App User ID
Troubleshooting
1. The app builds in Expo Go but monetisation does not work
Expected. The combined integration should be treated as a development-build setup, not a real Expo Go path.
Fix:
- create a development build
- confirm native modules are installed
- if native folders are stale, consider a clean prebuild path before rebuilding
2. PlacementNotFound for a placement that exists in the dashboard
In Expo development, Superwall dashboard changes do not hot-reload into the running app.
Fix:
- completely quit the development app
- relaunch the app
- then retry the placement
3. Purchase succeeded but premium UI stayed locked
Common causes:
- entitlement sync into Superwall is missing
- the wrong entitlement IDs are being mapped
- products exist in one dashboard but not the other
- entitlements do not match exactly
- Android selected the wrong base plan or offer
- the purchase is pending, not active
Fix:
- inspect
CustomerInfo.entitlements.active - inspect what
setSubscriptionStatusreceives - confirm Superwall and RevenueCat dashboard products and entitlements align
- inspect Android
basePlanIdandofferId
4. Webhooks or server notifications show the wrong user
Common causes:
identify()ran after the first purchase- the billing ID is not a UUID on iOS
- old SDK versions are still in use
- Android identifier passthrough is not configured as intended
- historical purchases happened before the identity fix
Fix:
- identify before purchase
- use the same UUID billing ID in RevenueCat and Superwall
- update SDK versions
- document that old historical purchases may remain linked to alias IDs
5. Account switching creates confusing identity states
Common cause:
- logging out RevenueCat before logging in the next known account
Fix:
- when switching account A to account B, call
logIn(nextUserId)directly - call Superwall
identify(nextUserId)directly - only use
logOut()when the product really supports guest state
6. Restore behaves differently than expected
Common causes:
- the RevenueCat restore-behaviour setting was never chosen intentionally
- the team expects strict account ownership but the dashboard is still on the default transfer model
- the product is guest-first but support expects login-first semantics
Fix:
- review
identity-and-restore-behaviour.md - confirm the dashboard setting
- document the chosen policy for support
7. Android purchase gets cancelled when the app backgrounds
Common cause:
- main activity launch mode is incompatible with Play verification flows
Fix:
- ensure Android launch mode is
standardorsingleTop
8. Existing subscribers do not show up after migration
Common cause:
- RevenueCat was added in observer mode or
purchasesAreCompletedBymode but no migration sync ran
Fix:
- call
syncPurchases()after the user logs in or during a deliberate migration checkpoint - do not add it to every app launch
9. Restoring old consumables or one-time purchases on Android is inconsistent
Common cause:
- the app uses affected versions around Google Billing Client 8 and older RevenueCat fixes are missing
Fix:
- upgrade
react-native-purchases - if one-time products matter, test reinstall and restore flows explicitly
- do not assume subscription testing covers one-time product recovery
10. The app seems right but dashboard targeting still feels wrong
Common causes:
- placements are too generic
- user attributes are missing
- the team manually gates too much in code and bypasses audience logic
Fix:
- register business-action placements
- set useful user attributes
- let Superwall audience targeting and entitlements do more of the work
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable
IGNORED_DIRS = {
".git",
".next",
".turbo",
".vscode",
".expo",
".idea",
"dist",
"build",
"coverage",
"node_modules",
}
ENV_FILE_CANDIDATES = [
".env",
".env.local",
".env.development",
".env.production",
".env.staging",
]
PLACEHOLDER_PATTERNS = [
re.compile(r"YOUR_[A-Z0-9_]+"),
re.compile(r"appl_?YOUR", re.IGNORECASE),
re.compile(r"goog_?YOUR", re.IGNORECASE),
re.compile(r"sw_[a-z]+_?YOUR", re.IGNORECASE),
re.compile(r"your[_-]?api[_-]?key", re.IGNORECASE),
re.compile(r"changeme", re.IGNORECASE),
]
@dataclass
class Finding:
name: str
level: str
ok: bool
detail: str
def load_json(path: Path) -> Any:
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
def parse_major_version(version: str | None) -> int | None:
if not version:
return None
match = re.search(r"(\d+)", version)
return int(match.group(1)) if match else None
def parse_version_tuple(version: str | None) -> tuple[int, ...] | None:
if not version:
return None
parts = re.findall(r"\d+", version)
return tuple(int(part) for part in parts) if parts else None
def version_gte(version: str | None, minimum: tuple[int, ...]) -> bool:
parsed = parse_version_tuple(version)
if not parsed:
return False
padded = parsed + (0,) * max(0, len(minimum) - len(parsed))
return padded[: len(minimum)] >= minimum
def detect_package_manager(root: Path) -> str:
if (root / "bun.lockb").exists():
return "bun"
if (root / "pnpm-lock.yaml").exists():
return "pnpm"
if (root / "yarn.lock").exists():
return "yarn"
if (root / "package-lock.json").exists():
return "npm"
return "unknown"
def find_app_config(root: Path) -> Path | None:
candidates = [
"app.json",
"app.config.json",
"app.config.js",
"app.config.ts",
"app.config.mjs",
"app.config.cjs",
]
for candidate in candidates:
path = root / candidate
if path.exists():
return path
return None
def merge_dependency_maps(package_json: dict[str, Any]) -> dict[str, str]:
merged: dict[str, str] = {}
for key in ("dependencies", "devDependencies", "peerDependencies"):
values = package_json.get(key, {})
if isinstance(values, dict):
for dep_name, version in values.items():
merged[str(dep_name)] = str(version)
return merged
def extract_plugins_from_json_config(config: dict[str, Any]) -> list[Any]:
expo = config.get("expo", config)
plugins = expo.get("plugins", [])
return plugins if isinstance(plugins, list) else []
def summarise_build_properties_from_json(config: dict[str, Any]) -> tuple[str | None, str | None, bool]:
plugins = extract_plugins_from_json_config(config)
for plugin in plugins:
if isinstance(plugin, list) and plugin:
if plugin[0] == "expo-build-properties" and len(plugin) > 1 and isinstance(plugin[1], dict):
android = plugin[1].get("android", {})
ios = plugin[1].get("ios", {})
min_sdk = android.get("minSdkVersion")
deployment_target = ios.get("deploymentTarget")
return (
str(min_sdk) if min_sdk is not None else None,
str(deployment_target) if deployment_target is not None else None,
True,
)
elif plugin == "expo-build-properties":
return (None, None, True)
return (None, None, False)
def summarise_build_properties_from_text(text: str) -> tuple[str | None, str | None, bool]:
has_plugin = "expo-build-properties" in text
min_sdk_match = re.search(r"minSdkVersion\s*[:=]\s*[\"']?(\d+)", text)
deployment_match = re.search(r"deploymentTarget\s*[:=]\s*[\"']?([0-9.]+)", text)
return (
min_sdk_match.group(1) if min_sdk_match else None,
deployment_match.group(1) if deployment_match else None,
has_plugin,
)
def iter_code_files(root: Path) -> Iterable[Path]:
valid_suffixes = {
".ts",
".tsx",
".js",
".jsx",
".mjs",
".cjs",
".json",
".xml",
".gradle",
".kt",
".java",
}
for path in root.rglob("*"):
if not path.is_file():
continue
if any(part in IGNORED_DIRS for part in path.parts):
continue
if path.suffix.lower() not in valid_suffixes:
continue
yield path
def search_patterns(root: Path, patterns: dict[str, re.Pattern[str]]) -> dict[str, list[str]]:
matches: dict[str, list[str]] = {name: [] for name in patterns}
for file_path in iter_code_files(root):
try:
text = file_path.read_text(encoding="utf-8")
except Exception:
continue
relative = str(file_path.relative_to(root))
for name, pattern in patterns.items():
if pattern.search(text):
matches[name].append(relative)
return matches
def read_text(path: Path) -> str | None:
try:
return path.read_text(encoding="utf-8")
except Exception:
return None
def find_env_values(root: Path) -> dict[str, str]:
found: dict[str, str] = {}
keys = [
"EXPO_PUBLIC_REVENUECAT_IOS_API_KEY",
"EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY",
"EXPO_PUBLIC_SUPERWALL_IOS_API_KEY",
"EXPO_PUBLIC_SUPERWALL_ANDROID_API_KEY",
]
for filename in ENV_FILE_CANDIDATES:
path = root / filename
if not path.exists():
continue
text = read_text(path)
if not text:
continue
for key in keys:
match = re.search(rf"^{re.escape(key)}\s*=\s*(.+)$", text, flags=re.MULTILINE)
if match:
found[key] = match.group(1).strip().strip('\"').strip("'")
return found
def contains_placeholder(value: str | None) -> bool:
if not value:
return False
return any(pattern.search(value) for pattern in PLACEHOLDER_PATTERNS)
def inspect_android_manifest(root: Path) -> dict[str, Any]:
manifest_path = root / "android" / "app" / "src" / "main" / "AndroidManifest.xml"
summary = {
"path": str(manifest_path.relative_to(root)) if manifest_path.exists() else None,
"launch_mode": None,
"billing_permission": False,
"has_revenuecat_backup_agent": False,
}
if not manifest_path.exists():
return summary
text = read_text(manifest_path) or ""
launch_mode_match = re.search(r'android:launchMode="([^"]+)"', text)
summary["launch_mode"] = launch_mode_match.group(1) if launch_mode_match else None
summary["billing_permission"] = "com.android.vending.BILLING" in text
summary["has_revenuecat_backup_agent"] = "RevenueCatBackupAgent" in text
return summary
def inspect_package_json(root: Path) -> tuple[dict[str, str], str | None]:
package_json_path = root / "package.json"
if not package_json_path.exists():
raise FileNotFoundError("Could not find package.json")
package_json = load_json(package_json_path)
dependencies = merge_dependency_maps(package_json)
return dependencies, dependencies.get("expo")
def main() -> int:
parser = argparse.ArgumentParser(
description="Validate a React Native Expo repository for a RevenueCat + Superwall integration.",
)
parser.add_argument(
"--project-root",
default=".",
help="Path to the Expo project root. Defaults to the current directory.",
)
parser.add_argument(
"--json",
action="store_true",
help="Emit machine-readable JSON instead of formatted text.",
)
args = parser.parse_args()
root = Path(args.project_root).resolve()
try:
dependencies, expo_version = inspect_package_json(root)
except Exception as exc:
print(str(exc), file=sys.stderr)
return 2
expo_major = parse_major_version(expo_version)
package_manager = detect_package_manager(root)
uses_expo_router = "expo-router" in dependencies or (root / "app").exists()
app_config_path = find_app_config(root)
app_config_summary: dict[str, Any] = {
"path": str(app_config_path.relative_to(root)) if app_config_path else None,
"has_build_properties": False,
"android_min_sdk": None,
"ios_deployment_target": None,
}
if app_config_path and app_config_path.suffix == ".json":
try:
app_config_json = load_json(app_config_path)
min_sdk, deployment_target, has_build_properties = summarise_build_properties_from_json(app_config_json)
app_config_summary.update(
{
"has_build_properties": has_build_properties,
"android_min_sdk": min_sdk,
"ios_deployment_target": deployment_target,
}
)
except Exception:
pass
elif app_config_path:
text = read_text(app_config_path)
if text is not None:
min_sdk, deployment_target, has_plugin = summarise_build_properties_from_text(text)
app_config_summary.update(
{
"has_build_properties": has_plugin,
"android_min_sdk": min_sdk,
"ios_deployment_target": deployment_target,
}
)
patterns = {
"Purchases.configure": re.compile(r"Purchases\.configure\s*\("),
"purchasesAreCompletedBy": re.compile(r"purchasesAreCompletedBy"),
"SuperwallProvider": re.compile(r"\bSuperwallProvider\b"),
"CustomPurchaseControllerProvider": re.compile(r"\bCustomPurchaseControllerProvider\b"),
"SubscriptionSync": re.compile(r"\bsetSubscriptionStatus\b|\baddCustomerInfoUpdateListener\b"),
"usePlacement": re.compile(r"\busePlacement\b|\bregisterPlacement\b"),
"Auth identity sync": re.compile(r"\bPurchases\.logIn\b|\bidentify\s*\("),
"Observer or sync migration path": re.compile(r"\bsyncPurchases\b|purchasesAreCompletedBy"),
"Superwall analytics events": re.compile(r"\buseSuperwallEvents\b"),
}
pattern_matches = search_patterns(root, patterns)
env_values = find_env_values(root)
manifest = inspect_android_manifest(root)
findings: list[Finding] = [
Finding(
name="Expo SDK 53 or newer",
level="core",
ok=expo_major is not None and expo_major >= 53,
detail=f"Detected expo dependency {expo_version!r}",
),
Finding(
name="expo-superwall installed",
level="core",
ok="expo-superwall" in dependencies,
detail=dependencies.get("expo-superwall", "missing"),
),
Finding(
name="react-native-purchases installed",
level="core",
ok="react-native-purchases" in dependencies,
detail=dependencies.get("react-native-purchases", "missing"),
),
Finding(
name="expo-build-properties installed",
level="core",
ok="expo-build-properties" in dependencies,
detail=dependencies.get("expo-build-properties", "missing"),
),
Finding(
name="App config has expo-build-properties plugin",
level="config",
ok=bool(app_config_summary["has_build_properties"]),
detail=app_config_summary["path"] or "no app config found",
),
Finding(
name="Android minSdkVersion >= 23",
level="config",
ok=(
app_config_summary["android_min_sdk"] is not None
and int(str(app_config_summary["android_min_sdk"])) >= 23
),
detail=str(app_config_summary["android_min_sdk"]),
),
Finding(
name="iOS deploymentTarget >= 15.1",
level="config",
ok=version_gte(str(app_config_summary["ios_deployment_target"]), (15, 1)),
detail=str(app_config_summary["ios_deployment_target"]),
),
Finding(
name="RevenueCat iOS public key present",
level="env",
ok=bool(env_values.get("EXPO_PUBLIC_REVENUECAT_IOS_API_KEY")),
detail=env_values.get("EXPO_PUBLIC_REVENUECAT_IOS_API_KEY", "missing"),
),
Finding(
name="RevenueCat Android public key present",
level="env",
ok=bool(env_values.get("EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY")),
detail=env_values.get("EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY", "missing"),
),
Finding(
name="Superwall iOS public key present",
level="env",
ok=bool(env_values.get("EXPO_PUBLIC_SUPERWALL_IOS_API_KEY")),
detail=env_values.get("EXPO_PUBLIC_SUPERWALL_IOS_API_KEY", "missing"),
),
Finding(
name="Superwall Android public key present",
level="env",
ok=bool(env_values.get("EXPO_PUBLIC_SUPERWALL_ANDROID_API_KEY")),
detail=env_values.get("EXPO_PUBLIC_SUPERWALL_ANDROID_API_KEY", "missing"),
),
Finding(
name="No obvious placeholder SDK keys in env files",
level="env",
ok=all(not contains_placeholder(value) for value in env_values.values()),
detail=", ".join(sorted(env_values)) if env_values else "no env values found",
),
Finding(
name="RevenueCat configured in code",
level="code",
ok=bool(pattern_matches["Purchases.configure"]),
detail=", ".join(pattern_matches["Purchases.configure"][:6]) or "not found",
),
Finding(
name="Superwall provider mounted",
level="code",
ok=bool(pattern_matches["SuperwallProvider"]),
detail=", ".join(pattern_matches["SuperwallProvider"][:6]) or "not found",
),
Finding(
name="Uses CustomPurchaseControllerProvider or observer-mode migration",
level="code",
ok=bool(
pattern_matches["CustomPurchaseControllerProvider"]
or pattern_matches["purchasesAreCompletedBy"]
),
detail=", ".join(
(pattern_matches["CustomPurchaseControllerProvider"][:3] + pattern_matches["purchasesAreCompletedBy"][:3])
)
or "not found",
),
Finding(
name="Subscription status sync present",
level="code",
ok=bool(pattern_matches["SubscriptionSync"]),
detail=", ".join(pattern_matches["SubscriptionSync"][:6]) or "not found",
),
Finding(
name="Placement registration present",
level="code",
ok=bool(pattern_matches["usePlacement"]),
detail=", ".join(pattern_matches["usePlacement"][:6]) or "not found",
),
Finding(
name="Identity sync present",
level="code",
ok=bool(pattern_matches["Auth identity sync"]),
detail=", ".join(pattern_matches["Auth identity sync"][:6]) or "not found",
),
Finding(
name="Android launchMode is standard or singleTop",
level="android",
ok=manifest["launch_mode"] in {"standard", "singleTop"} if manifest["path"] else False,
detail=manifest["launch_mode"] or ("manifest not found" if not manifest["path"] else "not set"),
),
Finding(
name="Android billing permission declared when native manifest exists",
level="android",
ok=manifest["billing_permission"] if manifest["path"] else False,
detail="present" if manifest["billing_permission"] else ("manifest not found" if not manifest["path"] else "missing"),
),
Finding(
name="RevenueCat backup agent present when native manifest exists",
level="android",
ok=manifest["has_revenuecat_backup_agent"] if manifest["path"] else False,
detail="present" if manifest["has_revenuecat_backup_agent"] else ("manifest not found" if not manifest["path"] else "missing"),
),
]
output = {
"project_root": str(root),
"package_manager": package_manager,
"uses_expo_router": uses_expo_router,
"expo_version": expo_version,
"app_config": app_config_summary,
"android_manifest": manifest,
"dependencies": {
"expo-superwall": dependencies.get("expo-superwall"),
"react-native-purchases": dependencies.get("react-native-purchases"),
"react-native-purchases-ui": dependencies.get("react-native-purchases-ui"),
"expo-build-properties": dependencies.get("expo-build-properties"),
},
"env_values": env_values,
"findings": [
{"name": finding.name, "level": finding.level, "ok": finding.ok, "detail": finding.detail}
for finding in findings
],
"code_matches": pattern_matches,
}
if args.json:
print(json.dumps(output, indent=2, sort_keys=True))
core_ok = all(f.ok for f in findings if f.level == "core")
return 0 if core_ok else 1
print(f"Project root: {root}")
print(f"Package manager: {package_manager}")
print(f"Uses Expo Router: {'yes' if uses_expo_router else 'no'}")
print(f"Expo dependency: {expo_version or 'missing'}")
print(f"App config: {app_config_summary['path'] or 'not found'}")
print()
current_section = None
for finding in findings:
if finding.level != current_section:
current_section = finding.level
print(f"{current_section.upper()} CHECKS")
print("-" * (len(current_section) + 7))
status = "PASS" if finding.ok else "WARN"
print(f"[{status}] {finding.name}: {finding.detail}")
if finding.level != "android":
continue
print()
print("DETECTED CODE MATCHES")
print("---------------------")
for label, files in pattern_matches.items():
if files:
print(f"[PASS] {label}: {', '.join(files[:8])}")
else:
print(f"[WARN] {label}: not found")
print()
print("RECOMMENDED NEXT STEPS")
print("----------------------")
next_steps: list[str] = []
if "expo-superwall" not in dependencies or "react-native-purchases" not in dependencies:
next_steps.append(
"Install required packages with: npx expo install expo-superwall react-native-purchases expo-build-properties"
)
if not app_config_summary["has_build_properties"]:
next_steps.append(
"Add the expo-build-properties plugin and set Android minSdkVersion 23 plus iOS deploymentTarget 15.1."
)
if not pattern_matches["Purchases.configure"]:
next_steps.append("Configure RevenueCat once at app startup.")
if not pattern_matches["SuperwallProvider"]:
next_steps.append("Wrap the app with SuperwallProvider.")
if not pattern_matches["CustomPurchaseControllerProvider"] and not pattern_matches["purchasesAreCompletedBy"]:
next_steps.append(
"Choose an architecture: add CustomPurchaseControllerProvider, or configure RevenueCat with purchasesAreCompletedBy for a migration path."
)
if not pattern_matches["SubscriptionSync"]:
next_steps.append("Add a RevenueCat-to-Superwall entitlement sync component.")
if not pattern_matches["Auth identity sync"]:
next_steps.append("Wire billing identity changes into the existing auth flow.")
if not pattern_matches["usePlacement"]:
next_steps.append("Register at least one named Superwall placement from a premium feature entry point.")
if any(contains_placeholder(value) for value in env_values.values()):
next_steps.append("Replace placeholder SDK keys in env files with real public keys.")
if manifest["path"] and manifest["launch_mode"] not in {"standard", "singleTop"}:
next_steps.append("Set Android launchMode to standard or singleTop.")
if not manifest["path"]:
next_steps.append(
"Managed Expo project detected with no native manifest. Re-check Android launchMode and billing manifest entries after prebuild or EAS build."
)
if not next_steps:
next_steps.append(
"The repo looks structurally ready. Review dashboard alignment, restore behaviour, identity design, and real-device testing paths."
)
for index, step in enumerate(next_steps, start=1):
print(f"{index}. {step}")
core_ok = all(f.ok for f in findings if f.level == "core")
return 0 if core_ok else 1
if __name__ == "__main__":
raise SystemExit(main())