From ab99d61958ad61ac755ae8c95f8579bd34cd602e Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Fri, 24 Oct 2025 13:31:04 -0500 Subject: [PATCH 1/3] Android web checkout docs --- .../linking-membership-to-iOS-app.mdx | 97 ++++++++++++- .../post-checkout-redirecting.mdx | 126 ++++++++++++++++- .../guides/web-checkout/using-revenuecat.mdx | 128 +++++++++++++++++- 3 files changed, 348 insertions(+), 3 deletions(-) diff --git a/content/docs/android/guides/web-checkout/linking-membership-to-iOS-app.mdx b/content/docs/android/guides/web-checkout/linking-membership-to-iOS-app.mdx index c4676ef4..bd9012ed 100644 --- a/content/docs/android/guides/web-checkout/linking-membership-to-iOS-app.mdx +++ b/content/docs/android/guides/web-checkout/linking-membership-to-iOS-app.mdx @@ -3,4 +3,99 @@ title: "Redeeming In-App" description: "Handle a deep link in your app and use the delegate methods." --- -../../../../shared/web-checkout/linking-membership-to-iOS-app.mdx \ No newline at end of file +After purchasing from a web paywall, the user will be redirected to your app by a deep link to redeem their purchase on device. +Please follow our [Post-Checkout Redirecting](/web-checkout-post-checkout-redirecting) guide to handle this user experience. + + + If you're using Superwall to handle purchases, then you don't need to do anything here. + + +If you're using your own `PurchaseController`, you will need to update the subscription status with the redeemed web entitlements. If you're using RevenueCat, you should follow our [Using RevenueCat](/web-checkout-using-revenuecat) guide. + +### Using a PurchaseController + +If you're using Google Play Billing in your PurchaseController, you'll need to merge the web entitlements with the device entitlements before setting the subscription status. +Here's an example of how you might do this: + +```kotlin +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.Purchase +import com.android.billingclient.api.QueryPurchasesParams +import com.superwall.sdk.Superwall +import com.superwall.sdk.models.entitlements.SubscriptionStatus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +suspend fun syncSubscriptionStatus(billingClient: BillingClient) { + withContext(Dispatchers.IO) { + val productIds = mutableSetOf() + + // Get the device entitlements from Google Play Billing + val params = QueryPurchasesParams.newBuilder() + .setProductType(BillingClient.ProductType.SUBS) + .build() + + val purchasesResult = billingClient.queryPurchasesAsync(params) + + // Collect purchased product IDs + purchasesResult.purchasesList.forEach { purchase -> + if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { + purchase.products.forEach { productId -> + productIds.add(productId) + } + } + } + + // Get products from Superwall and extract their entitlements + val storeProducts = Superwall.instance.getProducts(productIds) + val deviceEntitlements = storeProducts.flatMap { it.entitlements }.toSet() + + // Get the web entitlements from Superwall + val webEntitlements = Superwall.instance.entitlements.web + + // Merge the two sets of entitlements + val allEntitlements = deviceEntitlements + webEntitlements + + // Update subscription status on the main thread + withContext(Dispatchers.Main) { + if (allEntitlements.isNotEmpty()) { + Superwall.instance.setSubscriptionStatus( + SubscriptionStatus.Active(allEntitlements) + ) + } else { + Superwall.instance.setSubscriptionStatus(SubscriptionStatus.Inactive) + } + } + } +} +``` + +In addition to syncing the subscription status when purchasing and restoring, you'll need to sync it whenever `didRedeemLink(result:)` is called: + +```kotlin +import com.superwall.sdk.delegate.SuperwallDelegate +import com.superwall.sdk.models.redemption.RedemptionResult +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class SWDelegate(private val billingClient: BillingClient) : SuperwallDelegate { + private val coroutineScope = CoroutineScope(Dispatchers.Main) + + override fun didRedeemLink(result: RedemptionResult) { + coroutineScope.launch { + syncSubscriptionStatus(billingClient) + } + } +} +``` + +### Refreshing of web entitlements + +If you aren't using a Purchase Controller, the SDK will refresh the web entitlements every 24 hours. + +### Redeeming while a paywall is open + +If a redeem event occurs when a paywall is open, the SDK will track that as a restore event and the paywall will close. \ No newline at end of file diff --git a/content/docs/android/guides/web-checkout/post-checkout-redirecting.mdx b/content/docs/android/guides/web-checkout/post-checkout-redirecting.mdx index f86ef4e7..64c6db20 100644 --- a/content/docs/android/guides/web-checkout/post-checkout-redirecting.mdx +++ b/content/docs/android/guides/web-checkout/post-checkout-redirecting.mdx @@ -3,4 +3,128 @@ title: "Post-Checkout Redirecting" description: "Learn how to handle users redirecting back to your app after a web purchase." --- -../../../../shared/web-checkout/post-checkout-redirecting.mdx \ No newline at end of file +After a user completes a web purchase, Superwall needs to redirect them back to your app. You can configure this behavior in two ways: + +## Post-Purchase Behavior Modes + +You can configure how users are redirected after checkout in your [Application Settings](/web-checkout-configuring-stripe-keys-and-settings#post-purchase-behavior): + +### Redeem Mode (Default) + +Superwall manages the entire redemption experience: +- Users are automatically deep linked to your app with a redemption code +- Fallback to App Store/Play Store if the app isn't installed +- Redemption emails are sent automatically +- The SDK handles redemption via delegate methods (detailed below) + +This is the recommended mode for most apps. + +### Redirect Mode + +Redirect users to your own custom URL with purchase information: +- **When to use**: You want to show a custom success page, perform additional actions before redemption, or have your own deep linking infrastructure +- **What you receive**: Purchase data is passed as query parameters to your URL + +**Query Parameters Included**: +- `app_user_id` - The user's identifier from your app +- `email` - User's email address +- `stripe_subscription_id` - The Stripe subscription ID +- Any custom placement parameters you set + +**Example**: +``` +https://yourapp.com/success? + app_user_id=user_123& + email=user@example.com& + stripe_subscription_id=sub_1234567890& + campaign_id=summer_sale +``` + +You'll need to implement your own logic to handle the redirect and deep link users into your app. + +--- + +## Setting Up Deep Links + +Whether you're showing a checkout page in a browser or using the In-App Browser, the Superwall SDK relies on deep links to redirect back to your app. + +#### Prerequisites +1. [Configuring Stripe Keys and Settings](/web-checkout-configuring-stripe-keys-and-settings) +2. [Deep Links](/in-app-paywall-previews) + + + If you're not using Superwall to handle purchases, then you'll need to follow extra steps to redeem the web purchase in your app. + + +- [Using RevenueCat](/web-checkout-using-revenuecat) +- [Using a PurchaseController](/web-checkout-linking-membership-to-iOS-app#using-a-purchasecontroller) + +--- + +## Handling Redemption (Redeem Mode) + +When using Redeem mode (the default), handle the user experience when they're redirected back to your app using `SuperwallDelegate` methods: + +### willRedeemLink + +When your app opens via the deep link, we will call the delegate method `willRedeemLink()` before making a network call to redeem the code. +At this point, you might wish to display a loading indicator in your app so the user knows that the purchase is being redeemed. + +```kotlin +class SWDelegate : SuperwallDelegate { + override fun willRedeemLink() { + // Show a loading indicator to the user + showToast("Activating your purchase...") + } +} +``` + +You can manually dismiss the paywall at this point if needed, but note that the paywall will be dismissed automatically when the `didRedeemLink` method is called. + +### didRedeemLink + +After receiving a response from the network, we will call `didRedeemLink(result:)` with the result of redeeming the code. This result can be one of the following: + +- `RedemptionResult.Success`: The redemption succeeded and contains information about the redeemed code. +- `RedemptionResult.Error`: An error occurred while redeeming. You can check the error message via the error parameter. +- `RedemptionResult.ExpiredCode`: The code expired and contains information about whether a redemption email has been resent and an optional obfuscated email address. +- `RedemptionResult.InvalidCode`: The code that was redeemed was invalid. +- `RedemptionResult.ExpiredSubscription`: The subscription that the code redeemed has expired. + +On network failure, the SDK will retry up to 6 times before returning an `Error` `RedemptionResult` in `didRedeemLink(result:)`. + +Here, you should remove any loading UI you added in `willRedeemLink` and show a message to the user based on the result. If a paywall is presented, it will be dismissed automatically. + +```kotlin +class SWDelegate : SuperwallDelegate { + override fun didRedeemLink(result: RedemptionResult) { + when (result) { + is RedemptionResult.ExpiredCode -> { + showToast("Expired Link") + Log.d("Superwall", "Code expired: ${result.code}, ${result.expiredInfo}") + } + is RedemptionResult.Error -> { + showToast(result.error.message) + Log.d("Superwall", "Error: ${result.code}, ${result.error}") + } + is RedemptionResult.ExpiredSubscription -> { + showToast("Expired Subscription") + Log.d("Superwall", "Expired subscription: ${result.code}, ${result.redemptionInfo}") + } + is RedemptionResult.InvalidCode -> { + showToast("Invalid Link") + Log.d("Superwall", "Invalid code: ${result.code}") + } + is RedemptionResult.Success -> { + val email = result.redemptionInfo.purchaserInfo.email + if (email != null) { + Superwall.instance.setUserAttributes(mapOf("email" to email)) + showToast("Welcome, $email!") + } else { + showToast("Welcome!") + } + } + } + } +} +``` \ No newline at end of file diff --git a/content/docs/android/guides/web-checkout/using-revenuecat.mdx b/content/docs/android/guides/web-checkout/using-revenuecat.mdx index 25e0fbb7..a7f6e961 100644 --- a/content/docs/android/guides/web-checkout/using-revenuecat.mdx +++ b/content/docs/android/guides/web-checkout/using-revenuecat.mdx @@ -3,4 +3,130 @@ title: "Using RevenueCat" description: "Handle a deep link in your app and use the delegate methods to link web checkouts with RevenueCat." --- -../../../../shared/web-checkout/using-revenuecat.mdx \ No newline at end of file +After purchasing from a web paywall, the user will be redirected to your app by a deep link to redeem their purchase on device. Please follow our [Post-Checkout Redirecting](/web-checkout-post-checkout-redirecting) guide to handle this user experience. + + + If you're using Superwall to handle purchases, then you don't need to do anything here. + + +You only need to use a `PurchaseController` if you want end-to-end control of the purchasing pipeline. The recommended way to use RevenueCat with Superwall is by putting it in observer mode. + +If you're using your own `PurchaseController`, you should follow our [Redeeming In-App](/web-checkout-linking-membership-to-iOS-app) guide. + +### Using a PurchaseController with RevenueCat + +If you're using RevenueCat, you'll need to follow [steps 1 to 4 in their guide](https://www.revenuecat.com/docs/web/integrations/stripe) to set up Stripe with RevenueCat. Then, you'll need to +associate the RevenueCat customer with the Stripe subscription IDs returned from redeeming the code. You can do this by extracting the ids from the `RedemptionResult` and sending them to RevenueCat's API +by using the `didRedeemLink(result:)` delegate method: + +```kotlin +import com.revenuecat.purchases.Purchases +import com.superwall.sdk.Superwall +import com.superwall.sdk.delegate.SuperwallDelegate +import com.superwall.sdk.models.redemption.RedemptionResult +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject + +class SWDelegate : SuperwallDelegate { + private val client = OkHttpClient() + private val coroutineScope = CoroutineScope(Dispatchers.IO) + + // The user tapped on a deep link to redeem a code + override fun willRedeemLink() { + Log.d("Superwall", "[!] willRedeemLink") + // Optionally show a loading indicator here + } + + // Superwall received a redemption result and validated the purchase with Stripe. + override fun didRedeemLink(result: RedemptionResult) { + Log.d("Superwall", "[!] didRedeemLink: $result") + // Send Stripe IDs to RevenueCat to link purchases to the customer + + // Get a list of subscription ids tied to the customer. + val stripeSubscriptionIds = when (result) { + is RedemptionResult.Success -> result.stripeSubscriptionIds + else -> null + } ?: return + + val revenueCatStripePublicAPIKey = "strp....." // replace with your RevenueCat Stripe Public API Key + val appUserId = Purchases.sharedInstance.appUserID + + // In the background using coroutines... + coroutineScope.launch { + // For each subscription id, link it to the user in RevenueCat + stripeSubscriptionIds.forEach { stripeSubscriptionId -> + try { + val json = JSONObject().apply { + put("app_user_id", appUserId) + put("fetch_token", stripeSubscriptionId) + } + + val requestBody = json.toString() + .toRequestBody("application/json".toMediaType()) + + val request = Request.Builder() + .url("https://api.revenuecat.com/v1/receipts") + .post(requestBody) + .addHeader("Content-Type", "application/json") + .addHeader("Accept", "application/json") + .addHeader("X-Platform", "stripe") + .addHeader("Authorization", "Bearer $revenueCatStripePublicAPIKey") + .build() + + val response = client.newCall(request).execute() + val responseBody = response.body?.string() + + if (response.isSuccessful) { + Log.d("Superwall", "[!] Success: linked $stripeSubscriptionId to user $appUserId: $responseBody") + } else { + Log.e("Superwall", "[!] Error: unable to link $stripeSubscriptionId to user $appUserId. Response: $responseBody") + } + } catch (e: Exception) { + Log.e("Superwall", "[!] Error: unable to link $stripeSubscriptionId to user $appUserId", e) + } + } + + /// After all network calls complete, invalidate the cache + Purchases.sharedInstance.getCustomerInfo( + onSuccess = { customerInfo -> + /// If you're using RevenueCat's `UpdatedCustomerInfoListener`, or keeping Superwall Entitlements in sync + /// via RevenueCat's listener methods, you don't need to do anything here. Those methods will be + /// called automatically when this call fetches the most up to date customer info, ignoring any local caches. + + /// Otherwise, if you're manually calling `Purchases.sharedInstance.getCustomerInfo` to keep Superwall's entitlements + /// in sync, you should use the newly updated customer info here to do so. + + /// You could always access web entitlements here as well + /// val webEntitlements = Superwall.instance.entitlements.web + }, + onError = { error -> + Log.e("Superwall", "Error getting customer info", error) + } + ) + + // After all network calls complete, update UI on the main thread + withContext(Dispatchers.Main) { + // Perform UI updates on the main thread, like letting the user know their subscription was redeemed + } + } + } +} +``` + + + If you call `logIn` from RevenueCat's SDK, then you need to call the logic you've implemented + inside `didRedeemLink(result:)` again. For example, that means if `logIn` was invoked from + RevenueCat, you'd either abstract out this logic above into a function to call again, or simply + call this function directly. + + +The web entitlements will be returned along with other existing entitlements in the `CustomerInfo` object accessible via RevenueCat's SDK. + +If you're logging in and out of RevenueCat, make sure to resend the Stripe subscription IDs to RevenueCat's endpoint after logging in. \ No newline at end of file From b0f367deb199f8557b0c4154237239bc3e2e3f31 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Fri, 24 Oct 2025 13:46:13 -0500 Subject: [PATCH 2/3] UPdated web checkout docs --- .../linking-membership-to-iOS-app.mdx | 80 +++++++- .../post-checkout-redirecting.mdx | 158 ++++++++++++++- .../guides/web-checkout/using-revenuecat.mdx | 108 +++++++++- .../linking-membership-to-iOS-app.mdx | 89 ++++++++- .../post-checkout-redirecting.mdx | 188 +++++++++++++++++- 5 files changed, 618 insertions(+), 5 deletions(-) diff --git a/content/docs/expo/guides/web-checkout/linking-membership-to-iOS-app.mdx b/content/docs/expo/guides/web-checkout/linking-membership-to-iOS-app.mdx index c4676ef4..206ea370 100644 --- a/content/docs/expo/guides/web-checkout/linking-membership-to-iOS-app.mdx +++ b/content/docs/expo/guides/web-checkout/linking-membership-to-iOS-app.mdx @@ -3,4 +3,82 @@ title: "Redeeming In-App" description: "Handle a deep link in your app and use the delegate methods." --- -../../../../shared/web-checkout/linking-membership-to-iOS-app.mdx \ No newline at end of file +After purchasing from a web paywall, the user will be redirected to your app by a deep link to redeem their purchase on device. +Please follow our [Post-Checkout Redirecting](/web-checkout-post-checkout-redirecting) guide to handle this user experience. + + + If you're using Superwall to handle purchases, then you don't need to do anything here. + + +If you're using your own `PurchaseController`, you will need to update the subscription status with the redeemed web entitlements. If you're using RevenueCat, you should follow our [Using RevenueCat](/web-checkout-using-revenuecat) guide. + +### Using a PurchaseController + +If you're using a custom PurchaseController (with either iOS StoreKit or Android Play Billing), you'll need to merge the web entitlements with the device entitlements before setting the subscription status. +Here's an example of how you might do this: + +```typescript +import Superwall, { SubscriptionStatus, Entitlement } from 'expo-superwall/compat'; + +async function syncSubscriptionStatus(): Promise { + // Get the device entitlements from your purchase controller + // This will vary based on whether you're using RevenueCat, StoreKit, or Play Billing + const deviceEntitlements = await getDeviceEntitlements(); + + // Get the web entitlements from Superwall + const webEntitlements = await Superwall.shared.getWebEntitlements(); + + // Merge the two sets of entitlements + const allEntitlementIds = new Set([ + ...deviceEntitlements, + ...webEntitlements.map(e => e.id) + ]); + + // Update subscription status + if (allEntitlementIds.size > 0) { + const entitlements = Array.from(allEntitlementIds).map(id => + new Entitlement(id) + ); + Superwall.shared.setSubscriptionStatus( + SubscriptionStatus.Active(entitlements) + ); + } else { + Superwall.shared.setSubscriptionStatus(SubscriptionStatus.Inactive()); + } +} + +// Helper function to get device entitlements +// This is a simplified example - your implementation will depend on your purchase system +async function getDeviceEntitlements(): Promise { + // For RevenueCat: + // const customerInfo = await Purchases.getCustomerInfo(); + // return Object.keys(customerInfo.entitlements.active); + + // For custom StoreKit/Play Billing: + // Query your store's API for current purchases + // Extract entitlement IDs from those purchases + // Return array of entitlement IDs + + return []; // Replace with your actual implementation +} +``` + +In addition to syncing the subscription status when purchasing and restoring, you'll need to sync it whenever `didRedeemLink(result)` is called: + +```typescript +import { SuperwallDelegate, RedemptionResult } from 'expo-superwall/compat'; + +export class SWDelegate extends SuperwallDelegate { + async didRedeemLink(result: RedemptionResult): Promise { + await syncSubscriptionStatus(); + } +} +``` + +### Refreshing of web entitlements + +If you aren't using a Purchase Controller, the SDK will refresh the web entitlements every 24 hours. + +### Redeeming while a paywall is open + +If a redeem event occurs when a paywall is open, the SDK will track that as a restore event and the paywall will close. \ No newline at end of file diff --git a/content/docs/expo/guides/web-checkout/post-checkout-redirecting.mdx b/content/docs/expo/guides/web-checkout/post-checkout-redirecting.mdx index f86ef4e7..f90fdac8 100644 --- a/content/docs/expo/guides/web-checkout/post-checkout-redirecting.mdx +++ b/content/docs/expo/guides/web-checkout/post-checkout-redirecting.mdx @@ -3,4 +3,160 @@ title: "Post-Checkout Redirecting" description: "Learn how to handle users redirecting back to your app after a web purchase." --- -../../../../shared/web-checkout/post-checkout-redirecting.mdx \ No newline at end of file +After a user completes a web purchase, Superwall needs to redirect them back to your app. You can configure this behavior in two ways: + +## Post-Purchase Behavior Modes + +You can configure how users are redirected after checkout in your [Application Settings](/web-checkout-configuring-stripe-keys-and-settings#post-purchase-behavior): + +### Redeem Mode (Default) + +Superwall manages the entire redemption experience: +- Users are automatically deep linked to your app with a redemption code +- Fallback to App Store/Play Store if the app isn't installed +- Redemption emails are sent automatically +- The SDK handles redemption via delegate methods (detailed below) + +This is the recommended mode for most apps. + +### Redirect Mode + +Redirect users to your own custom URL with purchase information: +- **When to use**: You want to show a custom success page, perform additional actions before redemption, or have your own deep linking infrastructure +- **What you receive**: Purchase data is passed as query parameters to your URL + +**Query Parameters Included**: +- `app_user_id` - The user's identifier from your app +- `email` - User's email address +- `stripe_subscription_id` - The Stripe subscription ID +- Any custom placement parameters you set + +**Example**: +``` +https://yourapp.com/success? + app_user_id=user_123& + email=user@example.com& + stripe_subscription_id=sub_1234567890& + campaign_id=summer_sale +``` + +You'll need to implement your own logic to handle the redirect and deep link users into your app. + +--- + +## Setting Up Deep Links + +Whether you're showing a checkout page in a browser or using the In-App Browser, the Superwall SDK relies on deep links to redirect back to your app. + +#### Prerequisites +1. [Configuring Stripe Keys and Settings](/web-checkout-configuring-stripe-keys-and-settings) +2. [Deep Links](/in-app-paywall-previews) + + + If you're not using Superwall to handle purchases, then you'll need to follow extra steps to redeem the web purchase in your app. + + +- [Using RevenueCat](/web-checkout-using-revenuecat) +- [Using a PurchaseController](/web-checkout-linking-membership-to-iOS-app#using-a-purchasecontroller) + +--- + +## Handling Redemption (Redeem Mode) + +When using Redeem mode (the default), handle the user experience when they're redirected back to your app using `SuperwallDelegate` methods: + +### willRedeemLink + +When your app opens via the deep link, we will call the delegate method `willRedeemLink()` before making a network call to redeem the code. +At this point, you might wish to display a loading indicator in your app so the user knows that the purchase is being redeemed. + +```typescript +import { SuperwallDelegate } from 'expo-superwall/compat'; +import { Toast } from 'react-native-toast-message'; // or your preferred toast library + +export class SWDelegate extends SuperwallDelegate { + willRedeemLink(): void { + // Show a loading indicator to the user + Toast.show({ + type: 'info', + text1: 'Activating your purchase...', + }); + } +} +``` + +You can manually dismiss the paywall at this point if needed, but note that the paywall will be dismissed automatically when the `didRedeemLink` method is called. + +### didRedeemLink + +After receiving a response from the network, we will call `didRedeemLink(result)` with the result of redeeming the code. This result can be one of the following: + +- `RedemptionResult` with `type: 'success'`: The redemption succeeded and contains information about the redeemed code. +- `RedemptionResult` with `type: 'error'`: An error occurred while redeeming. You can check the error message via the error parameter. +- `RedemptionResult` with `type: 'expiredCode'`: The code expired and contains information about whether a redemption email has been resent and an optional obfuscated email address. +- `RedemptionResult` with `type: 'invalidCode'`: The code that was redeemed was invalid. +- `RedemptionResult` with `type: 'expiredSubscription'`: The subscription that the code redeemed has expired. + +On network failure, the SDK will retry up to 6 times before returning an `error` `RedemptionResult` in `didRedeemLink(result)`. + +Here, you should remove any loading UI you added in `willRedeemLink` and show a message to the user based on the result. If a paywall is presented, it will be dismissed automatically. + +```typescript +import { SuperwallDelegate, RedemptionResult } from 'expo-superwall/compat'; +import Superwall from 'expo-superwall/compat'; +import { Toast } from 'react-native-toast-message'; // or your preferred toast library + +export class SWDelegate extends SuperwallDelegate { + didRedeemLink(result: RedemptionResult): void { + switch (result.type) { + case 'expiredCode': + Toast.show({ + type: 'error', + text1: 'Expired Link', + }); + console.log('[!] code expired', result.code, result.expiredInfo); + break; + + case 'error': + Toast.show({ + type: 'error', + text1: result.error.message, + }); + console.log('[!] error', result.code, result.error); + break; + + case 'expiredSubscription': + Toast.show({ + type: 'error', + text1: 'Expired Subscription', + }); + console.log('[!] expired subscription', result.code, result.redemptionInfo); + break; + + case 'invalidCode': + Toast.show({ + type: 'error', + text1: 'Invalid Link', + }); + console.log('[!] invalid code', result.code); + break; + + case 'success': + const email = result.redemptionInfo?.purchaserInfo?.email; + if (email) { + Superwall.shared.setUserAttributes({ email }); + Toast.show({ + type: 'success', + text1: `Welcome, ${email}!`, + }); + } else { + Toast.show({ + type: 'success', + text1: 'Welcome!', + }); + } + break; + } + } +} +``` \ No newline at end of file diff --git a/content/docs/expo/guides/web-checkout/using-revenuecat.mdx b/content/docs/expo/guides/web-checkout/using-revenuecat.mdx index 25e0fbb7..f5efbb3a 100644 --- a/content/docs/expo/guides/web-checkout/using-revenuecat.mdx +++ b/content/docs/expo/guides/web-checkout/using-revenuecat.mdx @@ -3,4 +3,110 @@ title: "Using RevenueCat" description: "Handle a deep link in your app and use the delegate methods to link web checkouts with RevenueCat." --- -../../../../shared/web-checkout/using-revenuecat.mdx \ No newline at end of file +After purchasing from a web paywall, the user will be redirected to your app by a deep link to redeem their purchase on device. Please follow our [Post-Checkout Redirecting](/web-checkout-post-checkout-redirecting) guide to handle this user experience. + + + If you're using Superwall to handle purchases, then you don't need to do anything here. + + +You only need to use a `PurchaseController` if you want end-to-end control of the purchasing pipeline. The recommended way to use RevenueCat with Superwall is by putting it in observer mode. + +If you're using your own `PurchaseController`, you should follow our [Redeeming In-App](/web-checkout-linking-membership-to-iOS-app) guide. + +### Using a PurchaseController with RevenueCat + +If you're using RevenueCat, you'll need to follow [steps 1 to 4 in their guide](https://www.revenuecat.com/docs/web/integrations/stripe) to set up Stripe with RevenueCat. Then, you'll need to +associate the RevenueCat customer with the Stripe subscription IDs returned from redeeming the code. You can do this by extracting the ids from the `RedemptionResult` and sending them to RevenueCat's API +by using the `didRedeemLink(result)` delegate method: + +```typescript +import { SuperwallDelegate, RedemptionResult } from 'expo-superwall/compat'; +import Purchases from 'react-native-purchases'; + +export class SWDelegate extends SuperwallDelegate { + // The user tapped on a deep link to redeem a code + willRedeemLink(): void { + console.log('[!] willRedeemLink'); + // Optionally show a loading indicator here + } + + // Superwall received a redemption result and validated the purchase with Stripe. + async didRedeemLink(result: RedemptionResult): Promise { + console.log('[!] didRedeemLink', result); + // Send Stripe IDs to RevenueCat to link purchases to the customer + + // Get a list of subscription ids tied to the customer. + const stripeSubscriptionIds = + result.type === 'success' ? result.stripeSubscriptionIds : null; + + if (!stripeSubscriptionIds) { + return; + } + + const revenueCatStripePublicAPIKey = 'strp.....'; // replace with your RevenueCat Stripe Public API Key + const appUserId = Purchases.getAppUserID(); + + // In the background, process all subscription IDs + await Promise.all( + stripeSubscriptionIds.map(async (stripeSubscriptionId) => { + try { + const response = await fetch('https://api.revenuecat.com/v1/receipts', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Platform': 'stripe', + 'Authorization': `Bearer ${revenueCatStripePublicAPIKey}`, + }, + body: JSON.stringify({ + app_user_id: appUserId, + fetch_token: stripeSubscriptionId, + }), + }); + + const data = await response.json(); + + if (response.ok) { + console.log(`[!] Success: linked ${stripeSubscriptionId} to user ${appUserId}`, data); + } else { + console.error(`[!] Error: unable to link ${stripeSubscriptionId} to user ${appUserId}. Response:`, data); + } + } catch (error) { + console.error(`[!] Error: unable to link ${stripeSubscriptionId} to user ${appUserId}`, error); + } + }) + ); + + /// After all network calls complete, invalidate the cache + try { + const customerInfo = await Purchases.getCustomerInfo({ fetchPolicy: 'FETCH_CURRENT' }); + + /// If you're using RevenueCat's `addCustomerInfoUpdateListener`, or keeping Superwall Entitlements in sync + /// via RevenueCat's listener methods, you don't need to do anything here. Those methods will be + /// called automatically when this call fetches the most up to date customer info, ignoring any local caches. + + /// Otherwise, if you're manually calling `Purchases.getCustomerInfo` to keep Superwall's entitlements + /// in sync, you should use the newly updated customer info here to do so. + + /// You could always access web entitlements here as well + /// const webEntitlements = Superwall.shared.entitlements.web + + // Perform UI updates to let the user know their subscription was redeemed + // This runs automatically on the main thread in React Native + } catch (error) { + console.error('Error getting customer info', error); + } + } +} +``` + + + If you call `logIn` from RevenueCat's SDK, then you need to call the logic you've implemented + inside `didRedeemLink(result)` again. For example, that means if `logIn` was invoked from + RevenueCat, you'd either abstract out this logic above into a function to call again, or simply + call this function directly. + + +The web entitlements will be returned along with other existing entitlements in the `CustomerInfo` object accessible via RevenueCat's SDK. + +If you're logging in and out of RevenueCat, make sure to resend the Stripe subscription IDs to RevenueCat's endpoint after logging in. \ No newline at end of file diff --git a/content/docs/flutter/guides/web-checkout/linking-membership-to-iOS-app.mdx b/content/docs/flutter/guides/web-checkout/linking-membership-to-iOS-app.mdx index c4676ef4..0b0630d9 100644 --- a/content/docs/flutter/guides/web-checkout/linking-membership-to-iOS-app.mdx +++ b/content/docs/flutter/guides/web-checkout/linking-membership-to-iOS-app.mdx @@ -3,4 +3,91 @@ title: "Redeeming In-App" description: "Handle a deep link in your app and use the delegate methods." --- -../../../../shared/web-checkout/linking-membership-to-iOS-app.mdx \ No newline at end of file +After purchasing from a web paywall, the user will be redirected to your app by a deep link to redeem their purchase on device. +Please follow our [Post-Checkout Redirecting](/web-checkout-post-checkout-redirecting) guide to handle this user experience. + + + If you're using Superwall to handle purchases, then you don't need to do anything here. + + +If you're using your own `PurchaseController`, you will need to update the subscription status with the redeemed web entitlements. If you're using RevenueCat, you should follow our [Using RevenueCat](/web-checkout-using-revenuecat) guide. + +### Using a PurchaseController + +If you're using a custom PurchaseController (with either iOS StoreKit or Android Play Billing), you'll need to merge the web entitlements with the device entitlements before setting the subscription status. +Here's an example of how you might do this: + +```dart +import 'package:superwall_flutter/superwall_flutter.dart'; + +Future syncSubscriptionStatus() async { + // Get the device entitlements from your purchase controller + // This will vary based on whether you're using RevenueCat, StoreKit, or Play Billing + final deviceEntitlements = await getDeviceEntitlements(); + + // Get the web entitlements from Superwall + final webEntitlements = Superwall.shared.entitlements?.web ?? []; + + // Merge the two sets of entitlements + final allEntitlementIds = { + ...deviceEntitlements, + ...webEntitlements.map((e) => e.id), + }; + + // Update subscription status + if (allEntitlementIds.isNotEmpty) { + final entitlements = allEntitlementIds + .map((id) => Entitlement(id: id)) + .toSet(); + + await Superwall.shared.setSubscriptionStatus( + SubscriptionStatusActive(entitlements: entitlements), + ); + } else { + await Superwall.shared.setSubscriptionStatus( + SubscriptionStatusInactive(), + ); + } +} + +// Helper function to get device entitlements +// This is a simplified example - your implementation will depend on your purchase system +Future> getDeviceEntitlements() async { + // For RevenueCat: + // final customerInfo = await Purchases.getCustomerInfo(); + // return customerInfo.entitlements.active.keys.toList(); + + // For custom StoreKit/Play Billing: + // Query your store's API for current purchases + // Extract entitlement IDs from those purchases + // Return list of entitlement IDs + + return []; // Replace with your actual implementation +} +``` + +In addition to syncing the subscription status when purchasing and restoring, you'll need to sync it whenever `didRedeemLink(result)` is called: + +```dart +import 'package:superwall_flutter/superwall_flutter.dart'; + +class MySuperwallDelegate extends SuperwallDelegate { + @override + void didRedeemLink(RedemptionResult result) { + // Don't use async here directly, spawn a separate task + _handleRedemption(result); + } + + Future _handleRedemption(RedemptionResult result) async { + await syncSubscriptionStatus(); + } +} +``` + +### Refreshing of web entitlements + +If you aren't using a Purchase Controller, the SDK will refresh the web entitlements every 24 hours. + +### Redeeming while a paywall is open + +If a redeem event occurs when a paywall is open, the SDK will track that as a restore event and the paywall will close. \ No newline at end of file diff --git a/content/docs/flutter/guides/web-checkout/post-checkout-redirecting.mdx b/content/docs/flutter/guides/web-checkout/post-checkout-redirecting.mdx index f86ef4e7..baf1c6b0 100644 --- a/content/docs/flutter/guides/web-checkout/post-checkout-redirecting.mdx +++ b/content/docs/flutter/guides/web-checkout/post-checkout-redirecting.mdx @@ -3,4 +3,190 @@ title: "Post-Checkout Redirecting" description: "Learn how to handle users redirecting back to your app after a web purchase." --- -../../../../shared/web-checkout/post-checkout-redirecting.mdx \ No newline at end of file +After a user completes a web purchase, Superwall needs to redirect them back to your app. You can configure this behavior in two ways: + +## Post-Purchase Behavior Modes + +You can configure how users are redirected after checkout in your [Application Settings](/web-checkout-configuring-stripe-keys-and-settings#post-purchase-behavior): + +### Redeem Mode (Default) + +Superwall manages the entire redemption experience: +- Users are automatically deep linked to your app with a redemption code +- Fallback to App Store/Play Store if the app isn't installed +- Redemption emails are sent automatically +- The SDK handles redemption via delegate methods (detailed below) + +This is the recommended mode for most apps. + +### Redirect Mode + +Redirect users to your own custom URL with purchase information: +- **When to use**: You want to show a custom success page, perform additional actions before redemption, or have your own deep linking infrastructure +- **What you receive**: Purchase data is passed as query parameters to your URL + +**Query Parameters Included**: +- `app_user_id` - The user's identifier from your app +- `email` - User's email address +- `stripe_subscription_id` - The Stripe subscription ID +- Any custom placement parameters you set + +**Example**: +``` +https://yourapp.com/success? + app_user_id=user_123& + email=user@example.com& + stripe_subscription_id=sub_1234567890& + campaign_id=summer_sale +``` + +You'll need to implement your own logic to handle the redirect and deep link users into your app. + +--- + +## Setting Up Deep Links + +Whether you're showing a checkout page in a browser or using the In-App Browser, the Superwall SDK relies on deep links to redirect back to your app. + +#### Prerequisites +1. [Configuring Stripe Keys and Settings](/web-checkout-configuring-stripe-keys-and-settings) +2. [Deep Links](/in-app-paywall-previews) + + + If you're not using Superwall to handle purchases, then you'll need to follow extra steps to redeem the web purchase in your app. + + +- [Using RevenueCat](/web-checkout-using-revenuecat) +- [Using a PurchaseController](/web-checkout-linking-membership-to-iOS-app#using-a-purchasecontroller) + +--- + +## Handling Redemption (Redeem Mode) + +When using Redeem mode (the default), handle the user experience when they're redirected back to your app using `SuperwallDelegate` methods: + +### willRedeemLink + +When your app opens via the deep link, we will call the delegate method `willRedeemLink()` before making a network call to redeem the code. +At this point, you might wish to display a loading indicator in your app so the user knows that the purchase is being redeemed. + +```dart +import 'package:superwall_flutter/superwall_flutter.dart'; +import 'package:flutter/material.dart'; + +class MySuperwallDelegate extends SuperwallDelegate { + @override + void willRedeemLink() { + // Show a loading indicator to the user + print('Activating your purchase...'); + // You might show a SnackBar or loading dialog here + } +} +``` + +You can manually dismiss the paywall at this point if needed, but note that the paywall will be dismissed automatically when the `didRedeemLink` method is called. + +### didRedeemLink + +After receiving a response from the network, we will call `didRedeemLink(result)` with the result of redeeming the code. The result is a `RedemptionResult` which can be one of: + +- `RedemptionResult` with `type: RedemptionResultType.success`: The redemption succeeded and contains information about the redeemed code. +- `RedemptionResult` with `type: RedemptionResultType.error`: An error occurred while redeeming. You can check the error message via the error parameter. +- `RedemptionResult` with `type: RedemptionResultType.expiredCode`: The code expired and contains information about whether a redemption email has been resent and an optional obfuscated email address. +- `RedemptionResult` with `type: RedemptionResultType.invalidCode`: The code that was redeemed was invalid. +- `RedemptionResult` with `type: RedemptionResultType.expiredSubscription`: The subscription that the code redeemed has expired. + +On network failure, the SDK will retry up to 6 times before returning an `error` `RedemptionResult` in `didRedeemLink(result)`. + +Here, you should remove any loading UI you added in `willRedeemLink` and show a message to the user based on the result. If a paywall is presented, it will be dismissed automatically. + +```dart +import 'package:superwall_flutter/superwall_flutter.dart'; +import 'package:flutter/material.dart'; + +class MySuperwallDelegate extends SuperwallDelegate { + final BuildContext context; // Pass context if you need to show dialogs/snackbars + + MySuperwallDelegate(this.context); + + @override + void didRedeemLink(RedemptionResult result) { + switch (result.type) { + case RedemptionResultType.expiredCode: + _showMessage('Expired Link'); + print('[!] code expired: ${result.code}, ${result.expiredInfo}'); + break; + + case RedemptionResultType.error: + _showMessage(result.error?.message ?? 'An error occurred'); + print('[!] error: ${result.code}, ${result.error}'); + break; + + case RedemptionResultType.expiredSubscription: + _showMessage('Expired Subscription'); + print('[!] expired subscription: ${result.code}, ${result.redemptionInfo}'); + break; + + case RedemptionResultType.invalidCode: + _showMessage('Invalid Link'); + print('[!] invalid code: ${result.code}'); + break; + + case RedemptionResultType.success: + final email = result.redemptionInfo?.purchaserInfo?.email; + if (email != null) { + Superwall.shared.setUserAttributes({'email': email}); + _showMessage('Welcome, $email!'); + } else { + _showMessage('Welcome!'); + } + break; + } + } + + void _showMessage(String message) { + // Show a snackbar or toast message + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } +} +``` + +### Setting up the delegate + +Make sure to set your delegate when configuring Superwall: + +```dart +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + await Superwall.configure('pk_your_api_key'); + + // Set the delegate (you'll need access to BuildContext for UI operations) + runApp(MyApp()); +} + +class MyApp extends StatefulWidget { + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + @override + void initState() { + super.initState(); + // Set the delegate after the widget is initialized + WidgetsBinding.instance.addPostFrameCallback((_) { + Superwall.shared.setDelegate(MySuperwallDelegate(context)); + }); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: YourHomeScreen(), + ); + } +} +``` \ No newline at end of file From 7ddd46d2a32a86c4803ee1a407d0631b281f9fb3 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Fri, 24 Oct 2025 13:55:09 -0500 Subject: [PATCH 3/3] Update meta.json --- content/docs/expo/meta.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/content/docs/expo/meta.json b/content/docs/expo/meta.json index a9aaf4b8..8e13fd74 100644 --- a/content/docs/expo/meta.json +++ b/content/docs/expo/meta.json @@ -16,6 +16,10 @@ "quickstart/setting-user-properties", "quickstart/in-app-paywall-previews", + "---Common Use Cases---", + "guides/web-checkout", + "guides/3rd-party-analytics", + "---SDK Reference---", "sdk-reference/index", "sdk-reference/components", @@ -30,7 +34,6 @@ "guides/experimental-flags", "guides/advanced", "guides/migrations", - "guides/3rd-party-analytics", "[Troubleshooting](https://support.superwall.com/articles/4780985851-troubleshooting-expo-sdk)", "[Example App](https://github.com/superwall/expo-superwall/tree/main/example)" ]