Superwall Expo Vibe Coding Guide
This is an all-in-one prompt to get the Superwall SDK set up in your app.
Prerequisites
We recommend setting up the Superwall Docs MCP for any further setup/questions, but it is not required. If you want it, ask me to add it and refer me to the docs: https://superwall.com/docs/sdk/guides/vibe-coding
Integration Steps
- Install the Superwall SDK
- Configure it with my API key:
{{ PUBLIC_API_KEY }} - Present a paywall
- Set up deep link handling
- Ask me about how I want to handle these two items:
- Identifying the user
- Setting user attributes
How to Implement a Step
Implement each step one at a time. For each step, do the following:
- If the Superwall Docs MCP is available, get context using it before starting. Use it as the primary source of truth for Superwall knowledge.
- If the MCP is not available, use the SDK quickstart reference below as the source of truth.
- Then, help implement the step as described in the docs.
- If needed, ask for project-specific clarification.
- Finally, test the step or instruct on how to test it.
- Explain what you did, what step is complete, and what step is next.
Install the SDK
This guide is for Expo projects that want to integrate Superwall using our Expo SDK.
This doesn't sound like you?
- React Native app, new to Superwall → See our installation guide for bare React Native apps
- React Native app with existing Superwall SDK → See our migration guide
Important: Expo SDK 53+ Required
This SDK is exclusively compatible with Expo SDK version 53 and newer. For projects using older Expo versions, please use our legacy React Native SDK.
Expo Go is Not Supported
The Superwall SDK uses native modules that are not available in Expo Go. You must use an Expo Development Build to run your app with Superwall.
To create a development build:
npx expo run:ios
# or
npx expo run:androidIf you see the error Cannot find native module 'SuperwallExpo', see our Debugging guide for solutions.
To see the latest release, check out the Superwall Expo SDK repo.
bunx expo install expo-superwallpnpm dlx expo install expo-superwallnpx expo install expo-superwallyarn dlx expo install expo-superwallVersion Targeting
First, install the expo-build-properties config plugin if your Expo project hasn’t yet:
npx expo install expo-build-propertiesThen, add the following to your app.json or app.config.js file:
{
"expo": {
"plugins": [
...
[
"expo-build-properties",
{
"android": {
"minSdkVersion": 21
},
"ios": {
"deploymentTarget": "15.1" // or higher
}
}
]
]
}
}Configure the SDK
Superwall does not refetch its configuration during hot reloads. So, if you add products, edit a paywall, or otherwise change anything with Superwall, re-run your app to see those changes.
As soon as your app launches, you need to configure the SDK with your Public API Key. You'll retrieve this from the Superwall settings page.
Sign Up & Grab Keys
If you haven't already, sign up for a free account on Superwall. Then, when you're through to the Dashboard, click Settings from the panel on the left, click Keys and copy your Public API Key:

Initialize Superwall in your app
To use the Superwall SDK, you need to wrap your application (or the relevant part of it) with the <SuperwallProvider />. This provider initializes the SDK with your API key.
import { SuperwallProvider } from "expo-superwall";
// Replace with your actual Superwall API key
export default function App() {
return (
<SuperwallProvider apiKeys={{ ios: "YOUR_SUPERWALL_API_KEY" /* android: API_KEY */ }}>
{/* Your app content goes here */}
</SuperwallProvider>
);
}You've now configured Superwall!
Present Your First Paywall
Placements
With Superwall, you present paywalls by registering a Placement. Placements are the configurable entry points to show (or not show) paywalls based on your Campaigns as setup in your Superwall dashboard.
The placement campaign_trigger is set to show an example paywall by default.
Usage
The usePlacement hook allows you to register placements that you've configured in your Superwall dashboard.
The hook returns a registerPlacement function that you can use to register a placement.
import { usePlacement, useUser } from "expo-superwall";
import { Alert, Button, Text, View } from "react-native";
function PaywallScreen() {
const { registerPlacement, state: placementState } = usePlacement({
onError: (err) => console.error("Placement Error:", err),
onPresent: (info) => console.log("Paywall Presented:", info),
onDismiss: (info, result) =>
console.log("Paywall Dismissed:", info, "Result:", result),
});
const handleTriggerPlacement = async () => {
await registerPlacement({
placement: "campaign_trigger"
});
};
return (
<View style={{ padding: 20 }}>
<Button title="Show Paywall" onPress={handleTriggerPlacement} />
{placementState && (
<Text>Last Paywall Result: {JSON.stringify(placementState)}</Text>
)}
</View>
);
}User Management
Anonymous Users
Superwall automatically generates a random user ID that persists internally until the user deletes/reinstalls your app.
You can call Superwall.shared.reset() to reset this ID and clear any paywall assignments.
Identified Users
If you use your own user management system, call identify(userId:options:) when you have a user's identity. This will alias your userId with the anonymous Superwall ID enabling us to load the user’s assigned paywalls.
Calling Superwall.shared.reset() will reset the on-device userId to a random ID and clear the paywall assignments.
If your Expo app targets Android, pass { passIdentifiersToPlayStore: true } inside the options object you give to Superwall.configure. That ensures Google Play receives the plain appUserId as obfuscatedExternalAccountId; otherwise we send a hashed value. iOS builds ignore this option. Be sure the identifier satisfies Google's requirements and never includes PII.
import { useUser } from "expo-superwall";
function UserManagement() {
const { identify, signOut } = useUser();
// After retrieving a user's ID, e.g. from logging in or creating an account
const handleLogin = async (user) => {
await identify(user.id);
};
// When the user signs out
const handleSignOut = () => {
signOut();
};
return (
<>
<Button onPress={() => handleLogin(user)} title="Login" />
<Button onPress={handleSignOut} title="Sign Out" />
</>
);
}Advanced Use Case
You can supply an IdentityOptions object, whose property restorePaywallAssignments you can set to true. This tells the SDK to wait to restore paywall assignments from the server before presenting any paywalls. This should only be used in advanced use cases. If you expect users of your app to switch accounts or delete/reinstall a lot, you'd set this when users log in to an existing account.
Best Practices for a Unique User ID
- Do NOT make your User IDs guessable – they are public facing.
- Do NOT set emails as User IDs – this isn't GDPR compliant.
- Do NOT set IDFA or DeviceIds as User IDs – these are device specific / easily rotated by the operating system.
- Do NOT hardcode strings as User IDs – this will cause every user to be treated as the same user by Superwall.
Identifying users from App Store server events
On iOS, Superwall always supplies an appAccountToken with every StoreKit 2 transaction:
| Scenario | Value used for appAccountToken |
|---|---|
You’ve called Superwall.shared.identify(userId:) | The exact userId you passed |
You haven’t called identify yet | The UUID automatically generated for the anonymous user (the alias ID), without the $SuperwallAlias: prefix |
You passed a non‑UUID userId to identify | StoreKit rejects it; Superwall falls back to the alias UUID |
Because the SDK falls back to the alias UUID, purchase notifications sent to your server always include a stable, unique identifier—even before the user signs in.
On iOS, appAccountToken must be a UUID to be accepted by StoreKit.
If the userId you pass to identify is not a valid UUID string, StoreKit will not accept it for appAccountToken and the SDK will fall back to the anonymous alias UUID. This can cause the identifier in App Store Server Notifications to differ from the userId you passed. See Apple's docs: appAccountToken.
// Generate and use a UUID user ID in Swift
let userId = UUID().uuidString
Superwall.shared.identify(userId: userId)Presenting Paywalls
This allows you to register a placement to access a feature that may or may not be paywalled later in time. It also allows you to choose whether the user can access the feature even if they don't make a purchase.
Here's an example.
With Superwall
import { usePlacement } from "expo-superwall";
function WorkoutButton() {
const { registerPlacement } = usePlacement();
const handlePress = async () => {
// remotely decide if a paywall is shown and if
// navigation.startWorkout() is a paid-only feature
await registerPlacement({
placement: 'StartWorkout',
feature: () => {
navigation.navigate('LaunchedFeature', {
value: 'Non-gated feature launched',
});
},
});
};
return <Button onPress={handlePress} title="Start Workout" />;
}Without Superwall
function pressedWorkoutButton() {
if (user.hasActiveSubscription) {
navigation.startWorkout()
} else {
navigation.presentPaywall().then((result: boolean) => {
if (result) {
navigation.startWorkout()
} else {
// user didn't pay, developer decides what to do
}
})
}
}How registering placements presents paywalls
You can configure "StartWorkout" to present a paywall by creating a campaign, adding the placement, and adding a paywall to an audience in the dashboard.
- The SDK retrieves your campaign settings from the dashboard on app launch.
- When a placement is called that belongs to a campaign, audiences are evaluated on device and the user enters an experiment — this means there's no delay between registering a placement and presenting a paywall.
- If it's the first time a user is entering an experiment, a paywall is decided for the user based on the percentages you set in the dashboard
- Once a user is assigned a paywall for an audience, they will continue to see that paywall until you remove the paywall from the audience or reset assignments to the paywall.
- After the paywall is closed, the Superwall SDK looks at the Feature Gating value associated with your paywall, configurable from the paywall editor under General > Feature Gating (more on this below)
- If the paywall is set to Non Gated, the
feature:closure onregister(placement: ...)gets called when the paywall is dismissed (whether they paid or not) - If the paywall is set to Gated, the
feature:closure onregister(placement: ...)gets called only if the user is already paying or if they begin paying.
- If the paywall is set to Non Gated, the
- If no paywall is configured, the feature gets executed immediately without any additional network calls.
Given the low cost nature of how register works, we strongly recommend registering all core functionality in order to remotely configure which features you want to gate – without an app update.
import { usePlacement } from "expo-superwall";
// on the welcome screen
function SignUpButton() {
const { registerPlacement } = usePlacement();
const handlePress = async () => {
await registerPlacement({
placement: 'SignUp',
feature: () => {
navigation.beginOnboarding();
},
});
};
return <Button onPress={handlePress} title="Sign Up" />;
}
function WorkoutButton() {
const { registerPlacement } = usePlacement();
const handlePress = async () => {
await registerPlacement({
placement: 'StartWorkout',
feature: () => {
navigation.startWorkout();
},
});
};
return <Button onPress={handlePress} title="Start Workout" />;
}Automatically Registered Placements
The SDK automatically registers some internal placements which can be used to present paywalls:
Register. Everything.
To provide your team with ultimate flexibility, we recommend registering all of your analytics events, even if you don't pass feature blocks through. This way you can retroactively add a paywall almost anywhere – without an app update!
If you're already set up with an analytics provider, you'll typically have an Analytics.swift singleton (or similar) to disperse all your events from. Here's how that file might look:
Getting a presentation result
Use getPresentationResult(forPlacement:params:) when you need to ask the SDK what would happen when registering a placement — without actually showing a paywall. Superwall evaluates the placement and its audience filters then returns a PresentationResult. You can use this to adapt your app's behavior based on the outcome (such as showing a lock icon next to a pro feature if they aren't subscribed).
In short, this lets you peek at the outcome first and decide how your app should respond:
Tracking Subscription State
Superwall tracks the subscription state of a user for you. However, there are times in your app where you need to know if a user is on a paid plan or not. For example, you might want to conditionally show certain UI elements or enable premium features based on their subscription status.
Using the useUser hook
The easiest way to track subscription status in React Native is with the useUser hook from expo-superwall:
import { useUser } from "expo-superwall";
import { useEffect, useState } from "react";
import { View, Text } from "react-native";
function SubscriptionStatusExample() {
const { subscriptionStatus } = useUser();
const [isPaidUser, setIsPaidUser] = useState(false);
useEffect(() => {
if (subscriptionStatus?.status === "ACTIVE") {
console.log("User has active entitlements:", subscriptionStatus.entitlements);
setIsPaidUser(true);
} else {
console.log("User is on free plan");
setIsPaidUser(false);
}
}, [subscriptionStatus]);
return (
<View>
<Text>User Status: {isPaidUser ? "Pro" : "Free"}</Text>
<Text>Subscription: {subscriptionStatus?.status ?? "unknown"}</Text>
</View>
);
}The subscriptionStatus object has the following structure:
status: Can be"ACTIVE","INACTIVE", or"UNKNOWN"entitlements: An array of active entitlements (only present when status is"ACTIVE")
Listening for subscription status changes
You can also listen for real-time subscription status changes using the useSuperwallEvents hook:
import { useSuperwallEvents } from "expo-superwall";
import { useState } from "react";
function App() {
const [isPro, setIsPro] = useState(false);
useSuperwallEvents({
onSubscriptionStatusChange: (status) => {
if (status.status === "ACTIVE") {
console.log("User upgraded to pro!");
setIsPro(true);
} else {
console.log("User is on free plan");
setIsPro(false);
}
},
});
return (
// Your app content
);
}Checking for specific entitlements
If your app has multiple subscription tiers (e.g., Bronze, Silver, Gold), you can check for specific entitlements:
import { useUser } from "expo-superwall";
function PremiumFeature() {
const { subscriptionStatus } = useUser();
const hasGoldTier = subscriptionStatus?.entitlements?.some(
(entitlement) => entitlement.id === "gold"
);
if (hasGoldTier) {
return <GoldFeatureContent />;
}
return <UpgradePrompt />;
}Setting subscription status
When using Superwall with a custom purchase controller or third-party billing service, you need to manually update the subscription status. Here's how to sync with RevenueCat:
import { useUser } from "expo-superwall";
import { useEffect } from "react";
import Purchases from "react-native-purchases";
function SubscriptionSync() {
const { setSubscriptionStatus } = useUser();
useEffect(() => {
// Listen for RevenueCat customer info updates
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"
}))
});
});
// Get initial customer info
const syncInitialStatus = 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 (error) {
console.error("Failed to sync initial subscription status:", error);
}
};
syncInitialStatus();
return () => {
listener?.remove();
};
}, [setSubscriptionStatus]);
return null; // This component just handles the sync
}Using subscription status emitter
You can also listen to subscription status changes using the event emitter directly:
import Superwall from "expo-superwall";
import { useEffect } from "react";
function SubscriptionListener() {
useEffect(() => {
const subscription = Superwall.shared.subscriptionStatusEmitter.addListener(
"change",
(status) => {
switch (status.status) {
case "ACTIVE":
console.log("Active entitlements:", status.entitlements);
break;
case "INACTIVE":
console.log("No active subscription");
break;
case "UNKNOWN":
console.log("Subscription status unknown");
break;
}
}
);
return () => {
subscription.remove();
};
}, []);
return null;
}Superwall checks subscription status for you
Remember that the Superwall SDK uses its audience filters for determining when to show paywalls. You generally don't need to wrap your calls to register placements with subscription status checks:
// ❌ Unnecessary
if (subscriptionStatus?.status !== "ACTIVE") {
await Superwall.shared.register({ placement: "campaign_trigger" });
}
// ✅ Just register the placement
await Superwall.shared.register({ placement: "campaign_trigger" });In your audience filters, you can specify whether the subscription state should be considered, which keeps your codebase cleaner and puts the "Should this paywall show?" logic where it belongs—in the Superwall dashboard.
Setting User Attributes
By setting user attributes, you can display information about the user on the paywall. You can also define audiences in a campaign to determine which paywall to show to a user, based on their user attributes.
If a paywall uses the Set user attributes action, the merged attributes are sent back to your app via SuperwallDelegate.userAttributesDidChange(newAttributes:).
You do this by passing a [String: Any?] dictionary of attributes to Superwall.shared.setUserAttributes(_:):
import { useUser } from "expo-superwall";
function UserProfile() {
const { update } = useUser();
const updateUserAttributes = async (user) => {
await update({
name: user.name,
apnsToken: user.apnsTokenString,
email: user.email,
username: user.username,
profilePic: user.profilePicUrl,
stripe_customer_id: user.stripeCustomerId, // Optional: For Stripe checkout prefilling
});
};
// Or update using a function that receives old attributes
const incrementCounter = async () => {
await update((oldAttributes) => ({
...oldAttributes,
counter: (oldAttributes.counter || 0) + 1,
}));
};
return (
<>
<Button onPress={() => updateUserAttributes(user)} title="Update Profile" />
<Button onPress={incrementCounter} title="Increment Counter" />
</>
);
}Usage
This is a merge operation, such that if the existing user attributes dictionary
already has a value for a given property, the old value is overwritten. Other
existing properties will not be affected. To unset/delete a value, you can pass nil
for the value.
You can reference user attributes in audience filters to help decide when to display your paywall. When you configure your paywall, you can also reference the user attributes in its text variables. For more information on how to that, see Configuring a Paywall.
Handling Deep Links
- Previewing paywalls on your device before going live.
- Deep linking to specific campaigns.
- Web Checkout Post-Checkout Redirecting
Setup
There are two ways to deep link into your app: URL Schemes and Universal Links (iOS only).
Adding a Custom URL Scheme
iOS
Open Xcode. In your info.plist, add a row called URL Types. Expand the automatically created Item 0, and inside the URL identifier value field, type your Bundle ID, e.g., com.superwall.Superwall-SwiftUI. Add another row to Item 0 called URL Schemes and set its Item 0 to a URL scheme you'd like to use for your app, e.g., exampleapp. Your structure should look like this:

With this example, the app will open in response to a deep link with the format exampleapp://. You can view Apple's documentation to learn more about custom URL schemes.
Android
Add the following to your AndroidManifest.xml file:
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="exampleapp" />
</intent-filter>
</activity>This configuration allows your app to open in response to a deep link with the format exampleapp:// from your MainActivity class.
Adding a Universal Link (iOS only)
Only required for Web Checkout, otherwise you can skip this step.
Before configuring in your app, first create and configure your Stripe app on the Superwall Dashboard.
Add a new capability in Xcode
Select your target in Xcode, then select the Signing & Capabilities tab. Click on the + Capability button and select Associated Domains. This will add a new capability to your app.

Set the domain
Next, enter in the domain using the format applinks:[your-web-checkout-url]. This is the domain that Superwall will use to handle universal links. Your your-web-checkout-url value should match what's under the "Web Paywall Domain" section.

Testing
If your Stripe app's iOS Configuration is incomplete or incorrect, universal links will not work
You can verify that your universal links are working a few different ways. Keep in mind that it usually takes a few minutes for the associated domain file to propagate:
-
Use Branch's online validator: If you visit branch.io's online validator and enter in your web checkout URL, it'll run a similar check and provide the same output.
-
Test opening a universal link: If the validation passes from either of the two steps above, make sure visiting a universal link opens your app. Your link should be formatted as
https://[your web checkout link]/app-link/— which is simply your web checkout link with/app-link/at the end. This is easiest to test on device, since you have to tap an actual link instead of visiting one directly in Safari or another browser. In the iOS simulator, adding the link in the Reminders app works too:

Handling Deep Links
Previewing Paywalls
Next, build and run your app on your phone.
Then, head to the Superwall Dashboard. Click on Settings from the Dashboard panel on the left, then select General:
With the General tab selected, type your custom URL scheme, without slashes, into the Apple Custom URL Scheme field:
Next, open your paywall from the dashboard and click Preview. You'll see a QR code appear in a pop-up:
On your device, scan this QR code. You can do this via Apple's Camera app. This will take you to a paywall viewer within your app, where you can preview all your paywalls in different configurations.
Using Deep Links to Present Paywalls
Deep links can also be used as a placement in a campaign to present paywalls. Simply add deepLink_open as an placement, and the URL parameters of the deep link can be used as parameters! You can also use custom placements for this purpose. Read this doc for examples of both.
How is this guide?
Edit on GitHub