
Integrating Superwall Expo
- 63 installs
- 3 repo stars
- Updated June 29, 2026
- tristanmanchester/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
integrating-superwall-expo is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- integrating-superwall-expo
- AI & Agent Building
- AI-coding skill
Integrating Superwall Expo by the numbers
- 63 all-time installs (skills.sh)
- Ranked #6,243 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tristanmanchester/agent-skills --skill integrating-superwall-expoAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 63 |
|---|---|
| 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
Integrating Superwall in React Native Expo (expo-superwall)
Why this skill exists
Superwall’s Expo SDK (expo-superwall) is native-module based, so Expo Go won’t work and setup mistakes often look like “Cannot find native module 'SuperwallExpo'”. This skill provides a reliable, repeatable workflow to:
- install + configure
expo-superwallcorrectly in Expo SDK 53+ - present paywalls via placements (remote campaigns) and implement feature gating
- manage users + attributes, and keep subscription status in sync (including RevenueCat)
- wire deep links / web checkout, and troubleshoot the common integration failures
Non‑negotiables (check first)
1. Expo SDK 53+ is required for expo-superwall. 2. Expo Go is not supported. Use an Expo Development Build (npx expo run:ios|android or EAS dev client). 3. Target minimum OS versions:
- iOS deployment target: 15.1+
- Android minSdkVersion: 21+
4. Superwall config does not refetch during hot reload. After dashboard changes (paywalls/products/campaigns), fully restart the app.
Fast path checklist (copy/paste and tick)
- [ ] Confirm Expo SDK version (>=53) and you are not using Expo Go
- [ ] Install packages:
expo-superwall(+expo-build-propertiesif needed) - [ ] Set iOS/Android minimum versions via
expo-build-properties - [ ] Wrap app with
<SuperwallProvider>(+<SuperwallLoading/>,<SuperwallError/>,<SuperwallLoaded/>) - [ ] Register a first placement with
usePlacement().registerPlacement({ placement: "…" }) - [ ] (Optional) Implement feature gating via
feature: () => …callback - [ ] (Optional) Identify user + set user attributes with
useUser() - [ ] (Optional) Sync subscription status (built-in, or manual when using RevenueCat/custom billing)
- [ ] Rebuild dev client after native changes (
npx expo prebuild --cleanif needed) - [ ] Validate with:
node {baseDir}/scripts/check-setup.mjs
Default implementation pattern (recommended)
1) Install + platform targets
Follow the Superwall install guide in references/INSTALLATION.md.
2) Configure Superwall at the root
Use <SuperwallProvider /> at the top of your app (typically in App.tsx or your root layout). Add loading/error handling components so failures are visible.
See references/CONFIGURATION.md and assets/snippets/App.tsx.
3) Present paywalls & gate features via placements
Use the usePlacement hook to register placements. This is the “one API” that powers:
- presenting paywalls
- feature gating (run
featurecallback depending on dashboard “Gated/Non‑Gated”) - tracking paywall lifecycle state in React
See references/PLACEMENTS_AND_GATING.md.
4) Manage users, attributes, and subscription state
- Identify on login with
useUser().identify(userId) - Sign out with
useUser().signOut() - Set/merge attributes with
useUser().update({ … })(passnullto unset)
See:
references/USER_MANAGEMENT.mdreferences/SUBSCRIPTION_STATUS.md
5) Deep links & web checkout (optional)
If you want paywall previews from the dashboard, campaign-driven paywalls from deep links, or web checkout flows, wire deep links and forward incoming URLs to Superwall.
See references/DEEP_LINKS.md.
6) RevenueCat (optional, recommended approach)
If you want RevenueCat to own purchases while Superwall owns paywalls/experiments, use CustomPurchaseControllerProvider (hooks-based) and sync entitlement state to Superwall.
See references/REVENUECAT.md.
Troubleshooting playbook (use when things don’t work)
If you hit Cannot find native module 'SuperwallExpo', or paywalls never show:
1. Run the setup checker:
node {baseDir}/scripts/check-setup.mjs
2. Confirm you’re on a dev build, not Expo Go. 3. If you added expo-superwall to an existing project, regenerate native folders:
npx expo prebuild --clean(backs up any manual native edits first)
4. Rebuild the dev client (EAS or local) after adding native modules. 5. Clear caches and reinstall deps as needed.
Full triage checklist: references/TROUBLESHOOTING.md.
Output expectations when performing work in a repo
When applying this skill to a real codebase, prefer a small, reviewable set of changes:
package.jsondependency updatesapp.json/app.config.*plugin + platform target updates- Root provider wiring (
App.tsx/RootLayout) - A small “first placement” screen/button for verification
- Optional:
src/superwall/placements.tsconstants andsrc/superwall/sync.tsfor RevenueCat status sync
Include a short “How to test locally” note (dev build commands + which placement to trigger).
Reference map (read only when needed)
- Install + prerequisites:
references/INSTALLATION.md - Provider + options:
references/CONFIGURATION.md - Placements, gating, presentation results:
references/PLACEMENTS_AND_GATING.md - Users + attributes:
references/USER_MANAGEMENT.md - Subscription state:
references/SUBSCRIPTION_STATUS.md - Event listeners + analytics forwarding:
references/EVENTS_ANALYTICS.md - Deep links, previews, web checkout:
references/DEEP_LINKS.md - RevenueCat integration:
references/REVENUECAT.md - Debugging:
references/TROUBLESHOOTING.md - StoreKit testing:
references/STOREKIT_TESTING.md - Locale override:
references/LOCALE.md - Bare RN + Expo modules:
references/BARE_REACT_NATIVE.md - Migration / compat API:
references/MIGRATION_COMPAT.md
{
"expo": {
"name": "YourApp",
"slug": "your-app",
"scheme": "yourapp",
"plugins": [
[
"expo-build-properties",
{
"android": { "minSdkVersion": 21 },
"ios": { "deploymentTarget": "15.1" }
}
]
]
}
}
// App.tsx (snippet) — Superwall root wiring with loading/error gates
import React from "react";
import {
SuperwallProvider,
SuperwallLoading,
SuperwallLoaded,
SuperwallError,
} from "expo-superwall";
import { ActivityIndicator, Text, View } from "react-native";
export default function App() {
return (
<SuperwallProvider
apiKeys={{
ios: process.env.EXPO_PUBLIC_SUPERWALL_IOS_KEY!,
android: process.env.EXPO_PUBLIC_SUPERWALL_ANDROID_KEY!,
}}
onConfigurationError={(error) => {
console.error("Superwall configuration failed:", error);
}}
>
<SuperwallLoading>
<ActivityIndicator style={{ flex: 1 }} />
</SuperwallLoading>
<SuperwallError>
{(error) => (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<Text>Failed to initialise Superwall</Text>
<Text>{error}</Text>
</View>
)}
</SuperwallError>
<SuperwallLoaded>
{/* Your normal app routes/components */}
<RootNavigator />
</SuperwallLoaded>
</SuperwallProvider>
);
}
// Replace with your app root
function RootNavigator() {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<Text>Superwall is ready ✅</Text>
</View>
);
}
// DeepLinkBridge.tsx (snippet) — forward incoming URLs to Superwall
import * as Linking from "expo-linking";
import { useEffect } from "react";
// Note: depending on your SDK version, you may need to import a handler.
// Docs show: SuperwallExpoModule.handleDeepLink(url)
// Confirm the correct export in your project before wiring.
export function DeepLinkBridge() {
useEffect(() => {
const sub = Linking.addEventListener("url", ({ url }) => {
console.log("Incoming URL:", url);
// SuperwallExpoModule.handleDeepLink(url);
});
Linking.getInitialURL().then((url) => {
if (url) {
console.log("Initial URL:", url);
// SuperwallExpoModule.handleDeepLink(url);
}
});
return () => sub.remove();
}, []);
return null;
}
Using Expo SDK in bare React Native apps (without Expo)
When to use
- you have a “bare” React Native project (not an Expo managed app)
- you want Superwall’s Expo SDK anyway (it’s built as an Expo Module)
Steps (high level)
1) Install Expo Modules
npx install-expo-modules@latest2) Install Superwall
npx expo install expo-superwall3) iOS setup
- Open the iOS project in Xcode.
- Set deployment target to iOS 15.1+.
- Install pods:
cd ios
pod install4) Android setup
- Ensure your app targets minSdkVersion 21+ in
android/build.gradle.
Sources
- https://superwall.com/docs/expo/guides/bare-react-native
Configuration (SuperwallProvider + options)
Golden rule
Superwall does not refetch its configuration during hot reloads. If you add/edit products, paywalls, or campaigns in the dashboard, fully restart the app to see changes.
Minimal root setup (recommended)
Wrap your app with <SuperwallProvider /> and supply API keys:
import { SuperwallProvider } from "expo-superwall";
export default function App() {
return (
<SuperwallProvider
apiKeys={{
ios: process.env.EXPO_PUBLIC_SUPERWALL_IOS_KEY!,
android: process.env.EXPO_PUBLIC_SUPERWALL_ANDROID_KEY!,
}}
>
{/* app routes */}
</SuperwallProvider>
);
}Prefer EXPO_PUBLIC_* env vars
Expo only exposes env vars to the JS bundle if they’re prefixed with EXPO_PUBLIC_….
Add loading/error UI gates
Use the helper components to avoid “blank screen” failures:
import {
SuperwallProvider,
SuperwallLoading,
SuperwallLoaded,
SuperwallError,
} from "expo-superwall";
import { ActivityIndicator, Text, View } from "react-native";
export default function App() {
return (
<SuperwallProvider apiKeys={{ ios: "YOUR_KEY", android: "YOUR_KEY" }}>
<SuperwallLoading>
<ActivityIndicator style={{ flex: 1 }} />
</SuperwallLoading>
<SuperwallError>
{(error) => (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<Text>Superwall failed to load</Text>
<Text>{error}</Text>
</View>
)}
</SuperwallError>
<SuperwallLoaded>
<RootNavigator />
</SuperwallLoaded>
</SuperwallProvider>
);
}Options (PartialSuperwallOptions)
You can pass options to SuperwallProvider for advanced behaviour.
Common options used in Expo apps
Superwall exposes paywall-related options such as:
- observing purchases made by other SDKs (e.g., RevenueCat observer mode)
- passing identifiers to the Play Store on Android
- preloading paywalls
- external data collection toggles
Example (edit to your needs):
import type { PartialSuperwallOptions } from "expo-superwall";
export const options: PartialSuperwallOptions = {
paywalls: {
shouldObservePurchases: true,
isExternalDataCollectionEnabled: true,
shouldPreloadAllPaywalls: false,
passIdentifiersToPlayStore: true, // Android only
},
// networkEnvironment: "sandbox", // optional for testing (if supported in your SDK version)
};Android back button rerouting (optional)
If a paywall has “Reroute back button” enabled in the dashboard, you can intercept the back action on Android:
<SuperwallProvider
apiKeys={{ ios: "ios_key", android: "android_key" }}
options={{
paywalls: {
onBackPressed: (paywallInfo) => {
if (paywallInfo.identifier === "survey") {
showExitConfirmation();
return true; // consume back press
}
return false; // default dismissal
},
},
}}
/>Sources
- https://superwall.com/docs/expo/quickstart/configure
- https://superwall.com/docs/expo/sdk-reference/components/SuperwallProvider
- https://superwall.com/docs/expo/quickstart/configure-the-sdk
Deep links, paywall previews, and deepLink_open
What you get
- Preview paywalls from the dashboard on-device (QR code viewer)
- Trigger paywalls from URLs without hardcoding routing logic
- Support web checkout return flows (iOS universal links)
1) Deep link setup (app side)
Custom URL scheme
Superwall uses a custom URL scheme for paywall previews and some deep-link flows.
You can configure this in a few ways depending on your Expo workflow:
- Expo config (preferred for managed/prebuild): set
"scheme"inapp.jsonand rebuild your dev client. - Native iOS project: add a URL type in Xcode (
Info→URL Types) with your scheme. - Native Android project: add an
intent-filterfor your scheme.
Keep your scheme stable (e.g. myapp) and rebuild after changing it.
Wire incoming URLs to Superwall
To let Superwall drive paywall presentation via the standard placement deepLink_open, forward every incoming deep link URL to Superwall’s handler.
Docs show:
function handleUrl(url: string) {
SuperwallExpoModule.handleDeepLink(url);
}Implementation notes:
- The exact import path can vary by SDK version. Search your project / SDK exports for
handleDeepLink/SuperwallExpoModule. - If you can’t access the module directly, fall back to “manual routing” by parsing the URL and calling
registerPlacement("deepLink_open", params)yourself — but you lose Superwall’s built-in URL-to-params mapping.
Expo Linking example (practical wiring)
import * as Linking from "expo-linking";
import { useEffect } from "react";
export function DeepLinkBridge() {
useEffect(() => {
const sub = Linking.addEventListener("url", ({ url }) => {
// Forward to Superwall deep-link handler
// SuperwallExpoModule.handleDeepLink(url);
});
// Handle cold start link
Linking.getInitialURL().then((url) => {
if (url) {
// SuperwallExpoModule.handleDeepLink(url);
}
});
return () => sub.remove();
}, []);
return null;
}2) Campaign-driven paywalls from deep links
Instead of hardcoding:
/promo->"promoPlacement"/upgrade->"upgradePlacement"
…send the URL to handleDeepLink. Superwall fires the standard placement deepLink_open and exposes URL components (path, query params, host, etc.) to dashboard audience filters.
Example dashboard rule for myapp://promo?offer=summer:
params.pathispromoparams.offerissummer
This lets you add/change routes and paywalls in the dashboard with no app update.
3) Previewing paywalls from the dashboard
Once your scheme is configured: 1. In the dashboard: Settings → General → set your Apple Custom URL Scheme (without slashes). 2. Open a paywall in the dashboard and click Preview. 3. Scan the QR code on device to open the paywall viewer inside your app.
4) Web checkout (iOS universal link)
If you use Superwall web checkout, add Associated Domains in Xcode and set:
applinks:[your-web-checkout-url]
Testing tips:
- Branch has an online validator you can use against your web checkout domain.
- Test a link formatted as
https://[your-web-checkout-link]/app-link/by tapping it on device.
Sources
- Deep link setup + previews: https://superwall.com/docs/expo/quickstart/in-app-paywall-previews
- Deep link paywalls via handleDeepLink: https://superwall.com/docs/expo/guides/handling-deep-links
- Web checkout overview: https://superwall.com/docs/expo/guides/web-checkout
Events and analytics (useSuperwallEvents)
When to use
- forward paywall lifecycle events into your analytics stack (Segment, Amplitude, PostHog, etc.)
- debug issues via Superwall logs
- listen to custom paywall actions (e.g. button taps inside paywalls)
Low-level event hook: useSuperwallEvents()
This hook subscribes to native Superwall events and auto-cleans listeners on unmount.
Common callbacks include:
onPaywallPresent(paywallInfo)onPaywallDismiss(paywallInfo, result)onPaywallSkip(reason)onPaywallError(error)onSubscriptionStatusChange(status)onUserAttributesChange(newAttrs)onCustomPaywallAction(name)onLog({ level, scope, message, info, error })- plus
onSuperwallEvent(eventInfo)for raw/typed events
import { useSuperwallEvents } from "expo-superwall";
export function SuperwallAnalyticsBridge() {
useSuperwallEvents({
onPaywallPresent: (info) => analytics.track("paywall_present", { name: info.name }),
onPaywallDismiss: (info, result) =>
analytics.track("paywall_dismiss", { name: info.name, result: result.type }),
onPurchase: (params) =>
analytics.track("purchase_attempt", { productId: params.productId, platform: params.platform }),
onLog: ({ level, scope, message, error }) => {
if (level === "error") console.error("[Superwall]", scope, message, error);
},
});
return null;
}Custom paywall analytics
If your paywall triggers a custom action (configured in the paywall editor), listen via onCustomPaywallAction(name) and forward it.
Sources
- https://superwall.com/docs/expo/sdk-reference/hooks/useSuperwallEvents
- https://superwall.com/docs/expo/guides/3rd-party-analytics
Installation and prerequisites (Expo SDK)
Checklist
- Expo SDK 53+ is required.
- Expo Go is not supported (native modules). Use a Development Build.
- Target platform minimums:
- iOS deployment target 15.1+
- Android minSdkVersion 21+
Install packages
Use Expo’s installer so versions match your SDK:
npx expo install expo-superwallIf you’re not already using it, install the build-properties config plugin:
npx expo install expo-build-propertiesSet minimum platform targets (recommended)
Add expo-build-properties to app.json / app.config.js and set the required targets:
{
"expo": {
"plugins": [
[
"expo-build-properties",
{
"android": { "minSdkVersion": 21 },
"ios": { "deploymentTarget": "15.1" }
}
]
]
}
}Build and run (Development Build)
Superwall requires native code, so you must run via a dev client:
npx expo run:ios
# or
npx expo run:androidIf you use EAS Build, create a new development build after adding expo-superwall.
Common gotcha: “Cannot find native module 'SuperwallExpo'”
This almost always means one of:
- you ran in Expo Go
- native folders are stale (added the package after prebuild)
- you haven’t rebuilt the dev client since adding the native module
Follow references/TROUBLESHOOTING.md.
Sources
- https://superwall.com/docs/expo/quickstart/install
- https://superwall.com/docs/expo/guides/debugging
Setting a locale (override)
When to use
- you want Superwall localisation/currency to follow an in-app language picker
- you need deterministic locale for QA/testing
Approach
Pass a locale identifier via options.localeIdentifier when configuring Superwall.
<SuperwallProvider
apiKeys={{ ios: "ios_key", android: "android_key" }}
options={{
localeIdentifier: "en_GB",
}}
>
<App />
</SuperwallProvider>Verify
You can inspect device attributes (including locale) using useSuperwall().getDeviceAttributes().
Sources
- https://superwall.com/docs/expo/guides/setting-a-locale
- https://superwall.com/docs/expo/sdk-reference/hooks/useSuperwall
Migration / compat API (expo-superwall/compat)
When to use
- the repo already uses the legacy
react-native-superwallAPI style - you need minimal-diff migration while adopting the Expo SDK
- you need delegate / class-based patterns for older integrations
For new work, prefer the hooks-based API (SuperwallProvider, usePlacement, useUser, etc.).
Install
npx expo install expo-superwallConfigure (compat)
import Superwall from "expo-superwall/compat";
import { Platform } from "react-native";
await Superwall.configure({
apiKey: Platform.OS === "ios" ? IOS_KEY : ANDROID_KEY,
});Android identifiers (compat)
await Superwall.configure({
apiKey: Platform.OS === "ios" ? IOS_KEY : ANDROID_KEY,
options: Platform.OS === "android" ? { passIdentifiersToPlayStore: true } : undefined,
});Identify + attributes
await Superwall.shared.identify({ userId });
await Superwall.shared.setUserAttributes({
someCustomVal: "abc",
platform: Platform.OS,
timestamp: new Date().toISOString(),
});Register a placement (present paywall / gate feature)
Superwall.shared.register({
placement: "yourPlacementName",
feature() {
console.log("Feature called!");
},
});Listen to events via delegate
import {
EventType,
SuperwallDelegate,
type SuperwallEventInfo,
} from "expo-superwall/compat";
export class MyDelegate extends SuperwallDelegate {
handleSuperwallEvent(eventInfo: SuperwallEventInfo) {
switch (eventInfo.event.type) {
case EventType.paywallOpen:
console.log("Paywall opened");
break;
case EventType.paywallClose:
console.log("Paywall closed");
break;
}
}
}
// set delegate
const delegate = new MyDelegate();
await Superwall.shared.setDelegate(delegate);Sources
- https://superwall.com/docs/expo/guides/migrating-react-native
- https://superwall.com/docs/expo/sdk-reference/getPresentationResult
Placements, paywalls, and feature gating
Mental model
A placement is a named event in your app (e.g. "upgrade_button_tap", "start_workout") that Superwall can match to campaign rules in the dashboard. When you “register” a placement:
- Superwall evaluates campaign/audience rules on device
- it may present a paywall, skip it, or do nothing
- if you provided a
featurecallback, Superwall can gate whether it runs
Present a paywall (first test)
Use the usePlacement hook:
import { usePlacement } from "expo-superwall";
import { Button, Text, View } from "react-native";
export default function PaywallTest() {
const { registerPlacement, state } = usePlacement({
onPresent: (info) => console.log("Paywall presented:", info.name),
onDismiss: (_, result) => console.log("Paywall dismissed:", result.type),
onSkip: (reason) => console.log("Paywall skipped:", reason.type),
onError: (error) => console.error("Paywall error:", error),
});
return (
<View>
<Button
title="Trigger paywall"
onPress={() => registerPlacement({ placement: "campaign_trigger" })}
/>
<Text>State: {state.status}</Text>
</View>
);
}Feature gating via feature: () => …
Register a placement with a feature callback:
await registerPlacement({
placement: "StartWorkout",
feature: () => navigation.navigate("Workout"),
});How “Gated” vs “Non‑Gated” behaves
In the dashboard paywall editor you can choose a Feature Gating mode:
- Non‑Gated:
featureruns when the paywall dismisses (whether they purchase or not) - Gated:
featureruns only if the user is already subscribed or becomes subscribed
If no paywall is configured for the placement, feature executes immediately.
Pass parameters for targeting and analytics
Use params to attach context the dashboard can use in audience filters:
await registerPlacement({
placement: "upgrade_button_tap",
params: { screen: "settings", variant: "A" },
});Keep params small and avoid PII; use user attributes for user-level properties.
“Register everything” pattern (recommended)
Centralise placements in one module (e.g. src/superwall/placements.ts) so you can:
- keep names consistent
- add params consistently
- retrofit paywalls later without app updates
Peek at results without presenting a paywall
Use getPresentationResult to decide how to render UI (lock icon, etc.):
import { useSuperwall } from "expo-superwall";
import {
PresentationResultPaywall,
PresentationResultHoldout,
PresentationResultNoAudienceMatch,
} from "expo-superwall/compat";
const { getPresentationResult } = useSuperwall();
const result = await getPresentationResult("premium_feature", { source: "home" });
if (result instanceof PresentationResultPaywall) {
// would show a paywall
} else if (result instanceof PresentationResultHoldout) {
// user is in holdout group
} else if (result instanceof PresentationResultNoAudienceMatch) {
// would not show a paywall
}Sources
- https://superwall.com/docs/expo/sdk-reference/hooks/usePlacement
- https://superwall.com/docs/expo/quickstart/feature-gating
- https://superwall.com/docs/expo/sdk-reference/getPresentationResult
- https://superwall.com/docs/expo/quickstart/present-your-first-paywall
RevenueCat integration (recommended hooks-based approach)
Goal
Let Superwall handle paywalls/experiments while RevenueCat handles purchasing, receipts, and entitlement state.
High-level architecture
1. Your paywall is presented by Superwall via placements. 2. When the user taps “Purchase” on a paywall, Superwall calls your onPurchase callback. 3. Your callback executes the RevenueCat purchase flow. 4. You sync RevenueCat entitlements into Superwall via setSubscriptionStatus() so Superwall’s audience filters and gating logic stay correct.
Install RevenueCat (react-native-purchases)
Follow RevenueCat’s React Native setup, then ensure the package is installed:
npm i react-native-purchases
# or pnpm/yarn/bunWrap the app with CustomPurchaseControllerProvider
Place this provider outside SuperwallProvider:
import Purchases from "react-native-purchases";
import { Platform } from "react-native";
import {
CustomPurchaseControllerProvider,
SuperwallProvider,
} from "expo-superwall";
const RC_IOS_KEY = process.env.EXPO_PUBLIC_REVENUECAT_IOS_KEY!;
const RC_ANDROID_KEY = process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_KEY!;
function configureRevenueCat() {
Purchases.configure({
apiKey: Platform.OS === "ios" ? RC_IOS_KEY : RC_ANDROID_KEY,
});
}
export default function App() {
configureRevenueCat();
return (
<CustomPurchaseControllerProvider
controller={{
onPurchase: async ({ platform, productId }) => {
try {
// Fetch products (subscription + non-subscription) and purchase the matching productId.
// Exact RevenueCat calls depend on your catalogue and product category.
// On success: return void.
// On cancelled/failed: return a PurchaseResult or throw so Superwall records the right outcome.
} catch (e: any) {
return { type: "failed", error: String(e?.message ?? e) };
}
},
onPurchaseRestore: async () => {
try {
await Purchases.restorePurchases();
} catch (e: any) {
return { type: "failed", error: String(e?.message ?? e) };
}
},
}}
>
<SuperwallProvider
apiKeys={{
ios: process.env.EXPO_PUBLIC_SUPERWALL_IOS_KEY!,
android: process.env.EXPO_PUBLIC_SUPERWALL_ANDROID_KEY!,
}}
>
<RootNavigator />
<SubscriptionSync />
</SuperwallProvider>
</CustomPurchaseControllerProvider>
);
}Important: signal failures correctly
Superwall treats “resolved without failure” as a successful purchase. So if your purchase flow is cancelled or errors, return a failure/cancelled result or throw.
Sync subscription status from RevenueCat to Superwall
Use a background component that:
- listens for
CustomerInfoupdates - maps active entitlements to Superwall entitlements
- sets
"ACTIVE"or"INACTIVE"
import { useEffect } from "react";
import Purchases from "react-native-purchases";
import { useUser } from "expo-superwall";
export function SubscriptionSync() {
const { setSubscriptionStatus } = useUser();
useEffect(() => {
const listener = Purchases.addCustomerInfoUpdateListener((customerInfo) => {
const entitlementIds = Object.keys(customerInfo.entitlements.active);
setSubscriptionStatus({
status: entitlementIds.length === 0 ? "INACTIVE" : "ACTIVE",
entitlements: entitlementIds.map((id) => ({ id, type: "SERVICE_LEVEL" })),
});
});
// Initial sync
(async () => {
try {
const customerInfo = await Purchases.getCustomerInfo();
const entitlementIds = Object.keys(customerInfo.entitlements.active);
setSubscriptionStatus({
status: entitlementIds.length === 0 ? "INACTIVE" : "ACTIVE",
entitlements: entitlementIds.map((id) => ({ id, type: "SERVICE_LEVEL" })),
});
} catch (err) {
console.error("Failed to sync initial subscription status:", err);
}
})();
return () => listener?.remove();
}, [setSubscriptionStatus]);
return null;
}Legacy (compat) approach: PurchaseController
If your codebase still uses expo-superwall/compat, Superwall documents a PurchaseController integration pattern. Prefer migrating to the hooks-based provider above for new work.
See references/MIGRATION_COMPAT.md and the source doc below.
Sources
- https://superwall.com/docs/expo/guides/using-revenuecat
- https://superwall.com/docs/expo/sdk-reference/components/CustomPurchaseControllerProvider
StoreKit testing (iOS only)
When to use
- you want to test IAP flows locally without live App Store products
- you need deterministic purchase behaviour during development
Requirements
- Works in a native iOS build (dev client /
expo run:ios). - You need access to the generated
ios/project (prebuild).
Typical workflow
1. Ensure the project has an ios/ folder:
npx expo prebuild(or--cleanif needed)
2. Open the iOS project in Xcode. 3. Create or add a StoreKit configuration file (.storekit) to the project. 4. Attach the StoreKit config to the run scheme:
- Product → Scheme → Edit Scheme… → Run → Options → StoreKit Configuration
5. Run the app from Xcode (or rebuild your dev client).
Notes
- If your paywall products don’t resolve, confirm your StoreKit config contains the same product IDs used in Superwall.
- If you use RevenueCat, follow its StoreKit testing guidance too.
Sources
- https://superwall.com/docs/expo/guides/storekit-testing
Subscription status (tracking, listening, manual sync)
Built-in state
Superwall maintains a subscriptionStatus object you can read via useUser():
status:"ACTIVE" | "INACTIVE" | "UNKNOWN"entitlements: list of active entitlements (present when status is"ACTIVE")
import { useUser } from "expo-superwall";
export function ProBadge() {
const { subscriptionStatus } = useUser();
const isPro = subscriptionStatus?.status === "ACTIVE";
return <Text>{isPro ? "Pro" : "Free"}</Text>;
}Listen for changes (real time)
Use useSuperwallEvents for reactive UI updates:
import { useSuperwallEvents } from "expo-superwall";
useSuperwallEvents({
onSubscriptionStatusChange: (status) => {
console.log("New status:", status.status);
},
});Check for a specific entitlement
Useful for multi-tier products:
const hasGold = subscriptionStatus?.entitlements?.some((e) => e.id === "gold");When you must set subscription status manually
If purchases are handled externally (RevenueCat, custom billing, web checkout redemption flows), sync the state into Superwall:
import { useUser } from "expo-superwall";
const { setSubscriptionStatus } = useUser();
setSubscriptionStatus({
status: "ACTIVE",
entitlements: [{ id: "gold", type: "SERVICE_LEVEL" }],
});RevenueCat mapping (common pattern)
Map active RevenueCat entitlements to Superwall entitlements:
const entitlementIds = Object.keys(customerInfo.entitlements.active);
setSubscriptionStatus({
status: entitlementIds.length === 0 ? "INACTIVE" : "ACTIVE",
entitlements: entitlementIds.map((id) => ({ id, type: "SERVICE_LEVEL" })),
});Sources
- https://superwall.com/docs/expo/quickstart/tracking-subscription-state
- https://superwall.com/docs/expo/sdk-reference/hooks/useSuperwallEvents
- https://superwall.com/docs/expo/sdk-reference/hooks/useUser
Troubleshooting / debugging
Symptom: Cannot find native module 'SuperwallExpo'
Likely causes
- Running in Expo Go (Superwall is a native module)
- Added
expo-superwallafter the last prebuild/dev-client build, so native code isn’t in the app - Using Expo SDK < 53
Fix sequence (fastest → slowest)
1. Confirm you’re using a Development Build:
- local:
npx expo run:ios/npx expo run:android - EAS: rebuild the dev client
2. Confirm Expo SDK is 53+ (check package.json dependency expo) 3. Regenerate native projects if needed:
npx expo prebuild --clean
4. Reinstall deps + pods (iOS):
- delete
node_modules, reinstall cd ios && pod install(if you have anios/folder)
5. Clear Metro / Expo caches:
npx expo start -c
Symptom: paywall never shows / changes don’t appear
- Ensure the placement name matches exactly what’s in the dashboard campaign.
- Superwall doesn’t refetch config during hot reload: fully restart the app after dashboard edits.
- Confirm your placement is in a campaign and has a paywall attached to an audience that matches the device/user.
Symptom: Android-specific issues
- Ensure Android
minSdkVersionis 21+ viaexpo-build-properties. - If you need Play Store identifiers passed through, enable
passIdentifiersToPlayStorein options (Android only).
Symptom: Deep link previews don’t open the viewer
- Ensure your custom scheme is configured in the app and in Superwall dashboard settings.
- Rebuild the dev client after changing URL schemes.
- Confirm your app receives the URL (log with Expo Linking) and (if applicable) forwards to Superwall deep link handler.
Symptom: Purchases handled externally but Superwall gating is wrong
If RevenueCat/custom billing is used:
- you must sync subscription status into Superwall via
setSubscriptionStatus() - verify your entitlements IDs match what your Superwall campaigns expect
See references/REVENUECAT.md and references/SUBSCRIPTION_STATUS.md.
Sources
- https://superwall.com/docs/expo/guides/debugging
- https://superwall.com/docs/expo/quickstart/install
User management (identify, sign out, attributes)
When to use
- you have login/logout
- you want campaigns/audiences based on user properties
- you want to show user info on paywalls (text variables)
Core API: useUser()
The useUser hook provides:
identify(userId, options?)signOut()update(attrs | updaterFn)(merge semantics)- access to
userandsubscriptionStatus
import { useUser } from "expo-superwall";
import { Button, Text, View } from "react-native";
export function UserManagementScreen() {
const { identify, signOut, update, user, subscriptionStatus } = useUser();
return (
<View style={{ padding: 16 }}>
<Text>Subscription: {subscriptionStatus?.status ?? "unknown"}</Text>
<Text>User: {user?.appUserId ?? "anonymous"}</Text>
<Button title="Identify" onPress={() => identify(`user_${Date.now()}`)} />
<Button title="Sign out" onPress={() => signOut()} />
<Button
title="Set attributes"
onPress={() =>
update({
plan: "free",
locale: "en_GB",
})
}
/>
</View>
);
}Identify options
identify supports options such as:
restorePaywallAssignments?: boolean
Use this when you need to restore a user’s prior experiment/paywall assignments after re-identifying.
Setting user attributes
Attributes are merged with existing attributes:
- existing keys are overwritten
- other keys stay untouched
- pass
nullto unset/delete a value
await update({
email: user.email,
username: user.username,
profilePic: user.profilePicUrl,
stripe_customer_id: user.stripeCustomerId, // optional (web checkout prefill)
});
// Update based on previous attrs:
await update((old) => ({
...old,
counter: (old.counter || 0) + 1,
}));
// Unset a value:
await update({ profilePic: null });Integration attributes
Use setIntegrationAttributes() and getIntegrationAttributes() to attach identifiers that other systems may need (depending on your stack).
Sources
- https://superwall.com/docs/expo/sdk-reference/hooks/useUser
- https://superwall.com/docs/expo/quickstart/setting-user-properties
- https://superwall.com/docs/expo/guides/managing-users
#!/usr/bin/env node
/**
* check-setup.mjs
*
* Quick validator for integrating Superwall in an Expo project.
*
* Run from the project root:
* node {baseDir}/scripts/check-setup.mjs
*
* Options:
* --path <dir> Project directory (default: .)
* --json Output machine-readable JSON
*/
import fs from "node:fs";
import path from "node:path";
function parseArgs(argv) {
const out = { projectDir: ".", json: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === "--json") out.json = true;
else if (a === "--path" && argv[i + 1]) {
out.projectDir = argv[i + 1];
i++;
} else if (!a.startsWith("-") && out.projectDir === ".") {
out.projectDir = a;
}
}
return out;
}
function readJson(filePath) {
const raw = fs.readFileSync(filePath, "utf8");
return JSON.parse(raw);
}
function parseMajorVersion(version) {
// Handles "~53.0.0", "^53.0.0", "53.0.0", ">=53", etc.
const m = String(version).match(/(\d+)(?:\.\d+)?(?:\.\d+)?/);
return m ? Number(m[1]) : null;
}
function findExpoConfig(projectDir) {
const candidates = ["app.json", "app.config.json"];
for (const f of candidates) {
const p = path.join(projectDir, f);
if (fs.existsSync(p)) return { type: "json", file: p };
}
// app.config.js is common but requires execution; don't eval in a validator.
const jsCfg = path.join(projectDir, "app.config.js");
if (fs.existsSync(jsCfg)) return { type: "js", file: jsCfg };
return null;
}
function extractBuildProperties(expoConfig) {
const plugins = expoConfig?.expo?.plugins;
if (!Array.isArray(plugins)) return null;
for (const entry of plugins) {
// "expo-build-properties"
if (typeof entry === "string" && entry === "expo-build-properties") {
return { hasPlugin: true, config: null };
}
// ["expo-build-properties", { ios: {...}, android: {...}}]
if (Array.isArray(entry) && entry[0] === "expo-build-properties") {
return { hasPlugin: true, config: entry[1] ?? null };
}
}
return { hasPlugin: false, config: null };
}
function compareVersions(a, b) {
// a, b are strings like "15.1". Compare numerically by dot parts.
const pa = String(a).split(".").map((x) => Number(x));
const pb = String(b).split(".").map((x) => Number(x));
const n = Math.max(pa.length, pb.length);
for (let i = 0; i < n; i++) {
const da = pa[i] ?? 0;
const db = pb[i] ?? 0;
if (da > db) return 1;
if (da < db) return -1;
}
return 0;
}
function main() {
const args = parseArgs(process.argv);
const projectDir = path.resolve(process.cwd(), args.projectDir);
const report = {
projectDir,
ok: true,
checks: [],
hints: [],
};
const add = (name, status, details) => {
if (status !== "pass") report.ok = false;
report.checks.push({ name, status, details });
};
// package.json
const pkgPath = path.join(projectDir, "package.json");
if (!fs.existsSync(pkgPath)) {
add("package.json present", "fail", `Not found at ${pkgPath}`);
} else {
add("package.json present", "pass", pkgPath);
const pkg = readJson(pkgPath);
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
const expoVer = deps.expo;
const expoMajor = expoVer ? parseMajorVersion(expoVer) : null;
if (expoMajor == null) {
add("Expo SDK version (expo dep)", "warn", `Could not parse expo version from "${expoVer}"`);
report.hints.push("Superwall Expo SDK requires Expo SDK 53+.");
} else if (expoMajor >= 53) {
add("Expo SDK version (>=53)", "pass", `expo@${expoVer} (major ${expoMajor})`);
} else {
add("Expo SDK version (>=53)", "fail", `expo@${expoVer} (major ${expoMajor})`);
}
const hasSuperwall = Boolean(deps["expo-superwall"]);
add("Dependency: expo-superwall", hasSuperwall ? "pass" : "fail", hasSuperwall ? deps["expo-superwall"] : "Not installed");
const hasBuildProps = Boolean(deps["expo-build-properties"]);
add("Dependency: expo-build-properties", hasBuildProps ? "pass" : "warn", hasBuildProps ? deps["expo-build-properties"] : "Recommended to set iOS 15.1+ and Android minSdk 21+");
}
// app.json / app.config.*
const cfg = findExpoConfig(projectDir);
if (!cfg) {
add("Expo config (app.json/app.config.*)", "warn", "No app.json/app.config.json/app.config.js found");
} else if (cfg.type === "js") {
add("Expo config file", "warn", `Found ${path.basename(cfg.file)} (JS). Validator does not execute it.`);
report.hints.push("Ensure expo-build-properties is configured for iOS deploymentTarget 15.1+ and Android minSdkVersion 21+.");
} else {
add("Expo config file", "pass", path.basename(cfg.file));
try {
const expoConfig = readJson(cfg.file);
const scheme = expoConfig?.expo?.scheme;
if (scheme) {
add("Deep link scheme set", "pass", `scheme: ${scheme}`);
} else {
add("Deep link scheme set", "warn", "No expo.scheme found (optional unless using previews/deep links)");
}
const bp = extractBuildProperties(expoConfig);
if (!bp) {
add("expo.plugins present", "warn", "No plugins array found under expo.plugins");
} else if (!bp.hasPlugin) {
add("Plugin: expo-build-properties configured", "warn", "Not found in expo.plugins");
} else {
add("Plugin: expo-build-properties configured", "pass", bp.config ? "Configured with options" : "Present (no inline options)");
const iosTarget = bp.config?.ios?.deploymentTarget;
if (iosTarget) {
const ok = compareVersions(iosTarget, "15.1") >= 0;
add("iOS deploymentTarget >= 15.1", ok ? "pass" : "fail", `deploymentTarget: ${iosTarget}`);
} else {
add("iOS deploymentTarget >= 15.1", "warn", "Not set in expo-build-properties config");
}
const minSdk = bp.config?.android?.minSdkVersion;
if (typeof minSdk === "number") {
add("Android minSdkVersion >= 21", minSdk >= 21 ? "pass" : "fail", `minSdkVersion: ${minSdk}`);
} else {
add("Android minSdkVersion >= 21", "warn", "Not set in expo-build-properties config");
}
}
} catch (e) {
add("Parse Expo config", "fail", String(e));
}
}
// Dev build reminder
report.hints.push("Remember: Superwall does not run in Expo Go. Use a Development Build (expo run:* or EAS dev client).");
report.hints.push("If dashboard changes don't appear during development, fully restart the app (no hot reload refetch).");
if (args.json) {
process.stdout.write(JSON.stringify(report, null, 2) + "\n");
} else {
const pad = (s, n) => (s + " ".repeat(n)).slice(0, n);
console.log(`\nSuperwall Expo setup check — ${report.ok ? "OK ✅" : "Needs attention ⚠️"}`);
console.log(`Project: ${report.projectDir}\n`);
for (const c of report.checks) {
const icon = c.status === "pass" ? "✅" : c.status === "warn" ? "⚠️" : "❌";
console.log(`${icon} ${pad(c.name, 35)} ${c.details}`);
}
if (report.hints.length) {
console.log("\nHints:");
for (const h of report.hints) console.log(`- ${h}`);
}
console.log("");
}
process.exit(report.ok ? 0 : 1);
}
main();