From 92506055bd2eedd3155033e3ffb062425cb85215 Mon Sep 17 00:00:00 2001 From: Jeffrey Bunn Date: Wed, 5 Nov 2025 14:22:04 -0800 Subject: [PATCH 01/13] Added Paywalls entrypoint documentation --- docs/tools/paywalls.mdx | 4 + .../paywalls/custom-purchase-handling.mdx | 337 ++++++++++++++++++ sidebars.ts | 1 + 3 files changed, 342 insertions(+) create mode 100644 docs/tools/paywalls/custom-purchase-handling.mdx diff --git a/docs/tools/paywalls.mdx b/docs/tools/paywalls.mdx index 1a1e8ae8f..eff9c5b41 100644 --- a/docs/tools/paywalls.mdx +++ b/docs/tools/paywalls.mdx @@ -14,6 +14,10 @@ You can think of a Paywall as an optional feature of your Offering. An Offering Therefore, you can create a unique Paywall for each of your Offerings, and can create an unlimited number of Offerings & Paywalls for each variation you want to test with Experiments. +:::info Enterprise: Use Paywalls with Your Own Purchase Infrastructure +If you have existing in-app purchase infrastructure and want to use RevenueCat's paywall designer and rendering while maintaining your own purchase handling, see [Paywalls with Your Own Purchase Infrastructure](/tools/paywalls/custom-purchase-handling). +::: + ### Getting Started Our paywalls use native code to deliver smooth, intuitive experiences to your customers when you're ready to deliver them an Offering; and you can use our Dashboard to build your paywall from any of our existing templates, or start from scratch to create your own. Either way, you'll have full control of the components and their properties to modify the paywall to meet your needs. diff --git a/docs/tools/paywalls/custom-purchase-handling.mdx b/docs/tools/paywalls/custom-purchase-handling.mdx new file mode 100644 index 000000000..f061aa732 --- /dev/null +++ b/docs/tools/paywalls/custom-purchase-handling.mdx @@ -0,0 +1,337 @@ +--- +title: Paywalls with Your Own Purchase Infrastructure +slug: custom-purchase-handling +excerpt: Use RevenueCat's Paywalls with your existing in-app purchase infrastructure +hidden: false +--- + +import RCCodeBlock from "@site/src/components/RCCodeBlock"; + +import om1 from "@site/code_blocks/migrating-to-revenuecat/observer-mode_1.swift?raw"; +import om2 from "@site/code_blocks/migrating-to-revenuecat/observer-mode_2.m?raw"; +import om4 from "@site/code_blocks/migrating-to-revenuecat/observer-mode_4.java?raw"; +import om5 from "@site/code_blocks/migrating-to-revenuecat/observer-mode_5.dart?raw"; +import om6 from "@site/code_blocks/migrating-to-revenuecat/observer-mode_6.cs?raw"; +import om7 from "@site/code_blocks/migrating-to-revenuecat/observer-mode_7.js.txt?raw"; + +import content9 from "@site/code_blocks/customers/user-ids_9.swift?raw"; +import content10 from "@site/code_blocks/customers/user-ids_10.m?raw"; +import content11 from "@site/code_blocks/customers/user-ids_11.kt?raw"; +import content12 from "@site/code_blocks/customers/user-ids_12.java?raw"; +import content13 from "@site/code_blocks/customers/user-ids_13.js.txt?raw"; +import content14 from "@site/code_blocks/customers/user-ids_14.js.txt?raw"; +import content15 from "@site/code_blocks/customers/user-ids_15.js.txt?raw"; +import contentCapacitor2 from "@site/code_blocks/customers/user-ids_configure_with_user_capacitor.ts.txt?raw"; +import content16 from "@site/code_blocks/customers/user-ids_16.cs?raw"; +import contentKmp from "@site/code_blocks/customers/user-ids_kmp.kts?raw"; + +import userIdIOS from "@site/code_blocks/customers/user-ids_9.swift?raw"; +import userIdAndroid from "@site/code_blocks/customers/user-ids_11.kt?raw"; +import userIdRN from "@site/code_blocks/customers/user-ids_13.js.txt?raw"; +import userIdFlutter from "@site/code_blocks/customers/user-ids_14.js.txt?raw"; + +import paywallsIOSSwift from "@site/code_blocks/tools/paywalls_3_new.swift?raw"; +import paywallsIOSUIKit from "@site/code_blocks/tools/paywalls_4.swift?raw"; +import paywallsIOSObjC from "@site/code_blocks/tools/paywalls_5.m?raw"; +import paywallsAndroidCompose from "@site/code_blocks/tools/paywalls_3.kt?raw"; +import paywallsAndroidActivity from "@site/code_blocks/tools/paywalls_4.kt?raw"; +import paywallsAndroidJava from "@site/code_blocks/tools/paywalls_activity_java.java?raw"; +import paywallsRN from "@site/code_blocks/tools/paywalls_rn_2_new.ts.txt?raw"; +import paywallsFlutter from "@site/code_blocks/tools/paywalls_flutter_2_new.dart?raw"; +import paywallsKMP from "@site/code_blocks/tools/paywalls_kmp_1_new.kts?raw"; +import paywallsCapacitor from "@site/code_blocks/tools/paywalls_cap_1.ts.txt?raw"; +import paywallsUnity1 from "@site/code_blocks/tools/paywalls_unity_1.cs?raw"; +import paywallsUnity3 from "@site/code_blocks/tools/paywalls_unity_3.cs?raw"; + +# Paywalls with Your Own Purchase Infrastructure + +[RevenueCat Paywalls](/tools/paywalls) can be integrated into your app **even if you have existing in-app purchase infrastructure** that you want to maintain. + +This approach is designed for enterprises that want to leverage **RevenueCat's remote paywall configuration and native rendering capabilities** while continuing to use their own purchase handling, receipt validation, and subscription management systems. + +With this integration option, RevenueCat provides the paywall user interface and design tooling, while your existing code handles the actual purchase transactions. This allows you to **modernize your paywall presentation layer and testing capabilities** *without* [migrating](/migrating-to-revenuecat/migration-paths) your entire monetization infrastructure to RevenueCat. + +## What's Included + +When using Paywalls with your own purchase infrastructure, you get access to: + +- [**Paywall Design Editor**](/tools/paywalls/creating-paywalls#building-paywalls) - Visual editor with templates and drag-and-drop components +- [**One-Line Paywall Rendering**](/tools/paywalls/displaying-paywalls) - Native, cross-platform UI rendering in a single line of code +- [**Paywall Charts & Analytics**](/dashboard-and-metrics/charts/real-time-charts#coming-soon) - Insights into paywall performance and conversion metrics +- [**Experiments**](/tools/experiments-v1) - A/B/C/D testing capabilities for paywall and offer variations +- [**Targeting**](/tools/targeting) - Audience segmentation to serve different paywalls and offers to different users +- [**Customer Profile**](/dashboard-and-metrics/customer-profile) - Customer information and purchase history + +## What You Provide + +Your application remains responsible for: + +- **Purchase Handling** - Transaction processing via StoreKit, Google Play Billing, or Web +- **Receipt Validation** - Verifying purchases and managing subscription states +- **Subscription Management** - Handling entitlements and subscription lifecycle events +- **Analytics** (Optional) - Tracking purchase events and user behavior in your existing analytics stack + +## How It Works + +The integration follows a straightforward pattern: + +1. Configure your products in the RevenueCat dashboard +2. Design your paywalls remotely using RevenueCat's Paywall Designer +3. The RevenueCat SDK fetches and renders paywalls natively in your app +4. When users interact with the paywall, your existing purchase infrastructure handles the transaction + +## Setup + +### 1. Create a RevenueCat Project + +If you haven't already, [create a project](/projects/overview) in the RevenueCat dashboard. You'll receive an [API key](/projects/authentication#api-keys) that you'll use to configure the SDK. + +:::tip Server Notifications +We strongly recommend setting up [Platform Server Notifications](/platform-resources/server-notifications) to ensure RevenueCat receives timely updates about subscription events. +::: + +### 2. Configure Products + +[Configure your products](/projects/configuring-products) in the RevenueCat dashboard to match your existing product SKUs. This allows the paywall designer to reference your products when building paywall layouts. + +**For iOS apps:** +- Create your in-app purchases in [App Store Connect](/getting-started/entitlements/ios-products) +- Set up an [App Store Connect API key](/service-credentials/itunesconnect-app-specific-shared-secret/app-store-connect-api-key-configuration) to automatically import products and prices into RevenueCat +- Ensure your App Store Connect product IDs match your imported products in the RevenueCat dashboard + +**For Android apps:** +- Create your subscriptions in [Google Play Console](/getting-started/entitlements/android-products) +- Set up [Google Play service credentials](/service-credentials/creating-play-service-credentials) to automatically import products and prices into RevenueCat +- Ensure your Google Play product IDs match your imported products in the RevenueCat dashboard + +**For web apps:** + +Create your web products in the appropriate payment processor's dashboard and then import them into RevenueCat. +- [RevenueCat Web Billing](/web/web-billing/overview) +- [Stripe Billing](/web/integrations/stripe) +- [Paddle Billing](/web/integrations/paddle) +- Other payment processors + +### 3. Configure the SDK + +Configure the RevenueCat SDK with `purchasesAreCompletedBy` set to `.myApp`. This tells the SDK that your application will handle purchase transactions rather than RevenueCat. + + + +For more details on this configuration option, including StoreKit version selection and platform-specific considerations, see [Using the SDK with your own IAP Code](/migrating-to-revenuecat/sdk-or-not/finishing-transactions). + +### 4. Identifying Users + +If your app has user accounts or authentication, you can provide custom App User IDs when configuring the SDK. This allows you to track users across devices and platforms. + + + +For apps without authentication, RevenueCat will automatically generate anonymous user IDs. Learn more about [identifying users](/customers/identifying-customers). + +### 5. Create Offerings + +[Create an Offering](/offerings/overview) in the RevenueCat dashboard. An offering is the selection of products that are "offered" to a user on your paywall, and are required for Paywalls, Experiments, and Targeting. + +### 6. Design Your Paywall + +Use the [Paywall Designer](/tools/paywalls/creating-paywalls) in the RevenueCat dashboard to create your paywall. You can start with a template or build from scratch using the component-based editor. + +Key benefits of the Paywall Designer: +- **Remote Configuration** - Update paywall design and copy without app updates +- **Live Preview** - See changes in real-time across different orientations, screen sizes, and offer states +- **Localization** - Manage translations for multiple languages in one place +- **Templates** - Start from scratch or with one of our professionally-designed templates + +### 7. Display Paywalls + +First, ensure you've installed the RevenueCat UI SDK for your platform. This is a separate package from the core RevenueCat SDK and is required to display paywalls. See [Paywalls Installation](/tools/paywalls/installation) for platform-specific installation instructions. + +Display paywalls in your app using the RevenueCat UI SDK. The SDK handles fetching the paywall configuration, rendering the native UI, and providing callbacks for user interactions. + + + +For complete documentation on displaying paywalls, including advanced customization options, listeners, and all supported platforms, see [Displaying Paywalls](/tools/paywalls/displaying-paywalls). + +### 8. Handle Purchases + +When a user interacts with purchase buttons in the paywall, you'll need to handle the transaction with your existing purchase infrastructure. + +Your purchase handling code should: +- Detect when a user selects a product and initiates a purchase +- Process the purchase using your existing IAP implementation (StoreKit, Google Play Billing, etc.) +- Validate the receipt or transaction +- Update your internal subscription state +- Provide appropriate UI feedback to the user + +The exact integration pattern may vary depending on your implementation. For details on available paywall events and listeners for your platform, see [Displaying Paywalls](/tools/paywalls/displaying-paywalls). + +## Using Experiments + +Experiments allow you to run A/B/C/D tests in your app with direct revenue impact measured within the experiment results in the RevenueCat dashboard. When using paywalls with your own purchase infrastructure, you can still leverage Experiments to test different designs, copy, offers, or product arrangements. + +The SDK automatically enrolls users in experiments and serves the appropriate paywall variation. You can view experiment results in the RevenueCat dashboard to determine which variations perform best. + +Learn more about [creating and analyzing experiments](/tools/experiments-v1/experiments-overview-v1). + +## Using Targeting + +[Targeting](/tools/targeting) enables you to segment your customers into different audiences, and serve different Offerings (and therefore different paywalls) to these segments. You can create rules based on: + +- Custom attributes you define +- Country +- App +- App version +- RevenueCat SDK version +- Platform + +This allows you to tailor your monetization strategy to different user groups. For example, you might show different pricing to users in different countries or offer special promotions to specific user segments. + +Learn more about [setting up Targeting rules](/tools/targeting). + +## Testing + +During development, use RevenueCat's [Test Store](/test-and-launch/sandbox#test-store) to test your paywall implementation. The Test Store is automatically available for every project and allows you to simulate purchases without processing real transactions. + +When you're ready to test with real store configurations, use the [platform's Sandbox environment](/test-and-launch/sandbox#platform-sandboxes-applegoogleamazon). + +## Next Steps + +- [Contact Sales](https://www.revenuecat.com/talk-to-sales/) to discuss your use case and pricing +- Learn more about [Paywalls](/tools/paywalls) features and capabilities +- Explore [Experiments](/tools/experiments-v1/experiments-overview-v1) to optimize conversion rates +- Review [Targeting](/tools/targeting) capabilities to personalize paywalls for different audiences +- Explore what a full RevenueCat implementation looks like with our [Quickstart Guide](/getting-started/quickstart) \ No newline at end of file diff --git a/sidebars.ts b/sidebars.ts index 3064ff884..73cb92c05 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -155,6 +155,7 @@ const paywallsCategory = Category({ }), Page({ slug: "testing-paywalls" }), Page({ slug: "displaying-paywalls" }), + Page({ slug: "custom-purchase-handling" }), Page({ slug: "change-log" }), ], }), From fc6234985dff262c006e18b5a9a0c157e60584f3 Mon Sep 17 00:00:00 2001 From: Jeffrey Bunn Date: Wed, 5 Nov 2025 20:03:30 -0800 Subject: [PATCH 02/13] Added custom purchase implementation code with instructions --- .../paywalls_custom_purchase_android_1.kt | 63 ++++++++ .../paywalls_custom_purchase_android_2.kt | 66 +++++++++ .../paywalls_custom_purchase_ios_1.swift | 58 ++++++++ .../paywalls_custom_purchase_ios_2.swift | 49 ++++++ .../paywalls/custom-purchase-handling.mdx | 139 +++++++++--------- 5 files changed, 303 insertions(+), 72 deletions(-) create mode 100644 code_blocks/tools/paywalls_custom_purchase_android_1.kt create mode 100644 code_blocks/tools/paywalls_custom_purchase_android_2.kt create mode 100644 code_blocks/tools/paywalls_custom_purchase_ios_1.swift create mode 100644 code_blocks/tools/paywalls_custom_purchase_ios_2.swift diff --git a/code_blocks/tools/paywalls_custom_purchase_android_1.kt b/code_blocks/tools/paywalls_custom_purchase_android_1.kt new file mode 100644 index 000000000..601833f82 --- /dev/null +++ b/code_blocks/tools/paywalls_custom_purchase_android_1.kt @@ -0,0 +1,63 @@ +import android.app.Activity +import androidx.compose.runtime.* +import com.revenuecat.purchases.CustomerInfo +import com.revenuecat.purchases.Package +import com.revenuecat.purchases.Purchases +import com.revenuecat.purchases.PurchasesError +import com.revenuecat.purchases.PurchasesErrorCode +import com.revenuecat.purchases.ui.revenuecatui.Paywall +import com.revenuecat.purchases.ui.revenuecatui.PaywallOptions +import com.revenuecat.purchases.ui.revenuecatui.PurchaseLogic +import com.revenuecat.purchases.ui.revenuecatui.PurchaseLogicResult + +@Composable +fun MyPaywallScreen() { + val myPurchaseLogic = remember { + object : PurchaseLogic { + override suspend fun performPurchase( + activity: Activity, + rcPackage: Package, + ): PurchaseLogicResult { + return try { + // Your custom purchase implementation + // e.g., launch billing flow, validate with server + + // Sync with RevenueCat after successful purchase + Purchases.sharedInstance.syncPurchases() + + PurchaseLogicResult.Success + } catch (e: Exception) { + PurchaseLogicResult.Error( + errorDetails = PurchasesError( + code = PurchasesErrorCode.PurchaseInvalidError, + underlyingErrorMessage = e.message + ) + ) + } + } + + override suspend fun performRestore( + customerInfo: CustomerInfo + ): PurchaseLogicResult { + return try { + // Your custom restore implementation + + // Sync with RevenueCat + Purchases.sharedInstance.syncPurchases() + + PurchaseLogicResult.Success + } catch (e: Exception) { + PurchaseLogicResult.Error() + } + } + } + } + + val paywallOptions = PaywallOptions.Builder(dismissRequest = { + // Handle dismiss + }) + .setPurchaseLogic(myPurchaseLogic) + .build() + + Paywall(options = paywallOptions) +} diff --git a/code_blocks/tools/paywalls_custom_purchase_android_2.kt b/code_blocks/tools/paywalls_custom_purchase_android_2.kt new file mode 100644 index 000000000..3ba780354 --- /dev/null +++ b/code_blocks/tools/paywalls_custom_purchase_android_2.kt @@ -0,0 +1,66 @@ +import android.app.Activity +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.revenuecat.purchases.CustomerInfo +import com.revenuecat.purchases.Package +import com.revenuecat.purchases.Purchases +import com.revenuecat.purchases.PurchasesError +import com.revenuecat.purchases.PurchasesErrorCode +import com.revenuecat.purchases.ui.revenuecatui.PaywallOptions +import com.revenuecat.purchases.ui.revenuecatui.PurchaseLogicWithCallback +import com.revenuecat.purchases.ui.revenuecatui.PurchaseLogicResult +import com.revenuecat.purchases.ui.revenuecatui.views.PaywallView + +// In your Activity or Fragment +class MyActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + val paywallView = findViewById(R.id.paywallView) + + val myPurchaseLogic = object : PurchaseLogicWithCallback() { + override fun performPurchaseWithCompletion( + activity: Activity, + rcPackage: Package, + completion: (PurchaseLogicResult) -> Unit, + ) { + try { + // Your custom purchase implementation + + // Sync with RevenueCat + Purchases.sharedInstance.syncPurchases() + + completion(PurchaseLogicResult.Success) + } catch (e: Exception) { + completion( + PurchaseLogicResult.Error( + errorDetails = PurchasesError( + code = PurchasesErrorCode.PurchaseInvalidError, + underlyingErrorMessage = e.message + ) + ) + ) + } + } + + override fun performRestoreWithCompletion( + customerInfo: CustomerInfo, + completion: (PurchaseLogicResult) -> Unit, + ) { + // Your custom restore implementation + Purchases.sharedInstance.syncPurchases() + completion(PurchaseLogicResult.Success) + } + } + + val paywallOptions = PaywallOptions.Builder(dismissRequest = { + // Handle dismiss + }) + .setPurchaseLogic(myPurchaseLogic) + .build() + + paywallView.setPaywallOptions(paywallOptions) + } +} diff --git a/code_blocks/tools/paywalls_custom_purchase_ios_1.swift b/code_blocks/tools/paywalls_custom_purchase_ios_1.swift new file mode 100644 index 000000000..b2efd9fea --- /dev/null +++ b/code_blocks/tools/paywalls_custom_purchase_ios_1.swift @@ -0,0 +1,58 @@ +import SwiftUI +import RevenueCat +import RevenueCatUI + +struct ContentView: View { + @State private var showPaywall = false + + var body: some View { + VStack { + Button("Show Paywall") { + showPaywall = true + } + } + .sheet(isPresented: $showPaywall) { + PaywallView( + displayCloseButton: true, + performPurchase: { package in + do { + try await performCustomPurchase(package) + return (userCancelled: false, error: nil) + } catch { + return (userCancelled: false, error: error) + } + }, + performRestore: { + do { + try await performCustomRestore() + return (success: true, error: nil) + } catch { + return (success: false, error: error) + } + } + ) + .onPurchaseCompleted { customerInfo in + showPaywall = false + } + } + } + + private func performCustomPurchase(_ package: Package) async throws { + // Your custom purchase implementation + let transaction = try await package.storeProduct.purchase() + + // Validate with your server if needed + // try await validateWithYourServer(transaction) + + // Sync with RevenueCat + await Purchases.shared.syncPurchases() + } + + private func performCustomRestore() async throws { + // Restore all purchases + try await AppStore.sync() + + // Sync with RevenueCat + await Purchases.shared.syncPurchases() + } +} diff --git a/code_blocks/tools/paywalls_custom_purchase_ios_2.swift b/code_blocks/tools/paywalls_custom_purchase_ios_2.swift new file mode 100644 index 000000000..2fa5938e3 --- /dev/null +++ b/code_blocks/tools/paywalls_custom_purchase_ios_2.swift @@ -0,0 +1,49 @@ +import SwiftUI +import RevenueCat +import RevenueCatUI + +struct ContentView: View { + var body: some View { + YourAppContent() + .presentPaywallIfNeeded( + requiredEntitlementIdentifier: "pro", + myAppPurchaseLogic: MyAppPurchaseLogic( + performPurchase: { package in + do { + try await performCustomPurchase(package) + return (userCancelled: false, error: nil) + } catch { + return (userCancelled: false, error: error) + } + }, + performRestore: { + do { + try await performCustomRestore() + return (success: true, error: nil) + } catch { + return (success: false, error: error) + } + } + ), + purchaseCompleted: { customerInfo in + print("Purchase completed") + } + ) + } + + private func performCustomPurchase(_ package: Package) async throws { + // Your custom purchase implementation + let transaction = try await package.storeProduct.purchase() + + // Sync with RevenueCat + await Purchases.shared.syncPurchases() + } + + private func performCustomRestore() async throws { + // Restore all purchases + try await AppStore.sync() + + // Sync with RevenueCat + await Purchases.shared.syncPurchases() + } +} diff --git a/docs/tools/paywalls/custom-purchase-handling.mdx b/docs/tools/paywalls/custom-purchase-handling.mdx index f061aa732..bca580b99 100644 --- a/docs/tools/paywalls/custom-purchase-handling.mdx +++ b/docs/tools/paywalls/custom-purchase-handling.mdx @@ -30,18 +30,10 @@ import userIdAndroid from "@site/code_blocks/customers/user-ids_11.kt?raw"; import userIdRN from "@site/code_blocks/customers/user-ids_13.js.txt?raw"; import userIdFlutter from "@site/code_blocks/customers/user-ids_14.js.txt?raw"; -import paywallsIOSSwift from "@site/code_blocks/tools/paywalls_3_new.swift?raw"; -import paywallsIOSUIKit from "@site/code_blocks/tools/paywalls_4.swift?raw"; -import paywallsIOSObjC from "@site/code_blocks/tools/paywalls_5.m?raw"; -import paywallsAndroidCompose from "@site/code_blocks/tools/paywalls_3.kt?raw"; -import paywallsAndroidActivity from "@site/code_blocks/tools/paywalls_4.kt?raw"; -import paywallsAndroidJava from "@site/code_blocks/tools/paywalls_activity_java.java?raw"; -import paywallsRN from "@site/code_blocks/tools/paywalls_rn_2_new.ts.txt?raw"; -import paywallsFlutter from "@site/code_blocks/tools/paywalls_flutter_2_new.dart?raw"; -import paywallsKMP from "@site/code_blocks/tools/paywalls_kmp_1_new.kts?raw"; -import paywallsCapacitor from "@site/code_blocks/tools/paywalls_cap_1.ts.txt?raw"; -import paywallsUnity1 from "@site/code_blocks/tools/paywalls_unity_1.cs?raw"; -import paywallsUnity3 from "@site/code_blocks/tools/paywalls_unity_3.cs?raw"; +import paywallsCustomIOS1 from "@site/code_blocks/tools/paywalls_custom_purchase_ios_1.swift?raw"; +import paywallsCustomIOS2 from "@site/code_blocks/tools/paywalls_custom_purchase_ios_2.swift?raw"; +import paywallsCustomAndroid1 from "@site/code_blocks/tools/paywalls_custom_purchase_android_1.kt?raw"; +import paywallsCustomAndroid2 from "@site/code_blocks/tools/paywalls_custom_purchase_android_2.kt?raw"; # Paywalls with Your Own Purchase Infrastructure @@ -56,7 +48,7 @@ With this integration option, RevenueCat provides the paywall user interface and When using Paywalls with your own purchase infrastructure, you get access to: - [**Paywall Design Editor**](/tools/paywalls/creating-paywalls#building-paywalls) - Visual editor with templates and drag-and-drop components -- [**One-Line Paywall Rendering**](/tools/paywalls/displaying-paywalls) - Native, cross-platform UI rendering in a single line of code +- [**Native Paywall Rendering**](/tools/paywalls/displaying-paywalls) - Native, cross-platform UI rendering with the RevenueCat UI SDK - [**Paywall Charts & Analytics**](/dashboard-and-metrics/charts/real-time-charts#coming-soon) - Insights into paywall performance and conversion metrics - [**Experiments**](/tools/experiments-v1) - A/B/C/D testing capabilities for paywall and offer variations - [**Targeting**](/tools/targeting) - Audience segmentation to serve different paywalls and offers to different users @@ -217,87 +209,90 @@ Key benefits of the Paywall Designer: First, ensure you've installed the RevenueCat UI SDK for your platform. This is a separate package from the core RevenueCat SDK and is required to display paywalls. See [Paywalls Installation](/tools/paywalls/installation) for platform-specific installation instructions. -Display paywalls in your app using the RevenueCat UI SDK. The SDK handles fetching the paywall configuration, rendering the native UI, and providing callbacks for user interactions. +When using `purchasesAreCompletedBy: .myApp`, you must provide custom purchase and restore logic to the paywall. The SDK will call your handlers when users interact with purchase buttons. + +#### iOS Implementation + +**Key requirements for iOS:** +- Provide both `performPurchase` AND `performRestore` callbacks +- Return tuple: `(userCancelled: Bool, error: Error?)` for purchase +- Return tuple: `(success: Bool, error: Error?)` for restore +- Call `Purchases.shared.syncPurchases()` after successful transactions + +#### Android Implementation + + -For complete documentation on displaying paywalls, including advanced customization options, listeners, and all supported platforms, see [Displaying Paywalls](/tools/paywalls/displaying-paywalls). +**Key requirements for Android:** +- Implement `PurchaseLogic` interface (suspend functions) or `PurchaseLogicWithCallback` (callbacks) +- Return `PurchaseLogicResult.Success`, `.Cancellation`, or `.Error(errorDetails)` +- Pass your `PurchaseLogic` implementation to `PaywallOptions.Builder` +- Call `Purchases.sharedInstance.syncPurchases()` after successful transactions + +:::info Purchase Callbacks +When using custom purchase handling, standard `PaywallListener` callbacks (like `onPurchaseCompleted`) do NOT fire on Android. Your `PurchaseLogic` implementation controls the entire flow. On iOS, lifecycle callbacks like `onPurchaseStarted` and `onPurchaseCompleted` still fire. +::: + +For complete documentation on displaying paywalls, see [Displaying Paywalls](/tools/paywalls/displaying-paywalls). + +### 8. Implementing Purchase Handlers + +In the paywall integration above, you provided `performPurchase` and `performRestore` callbacks with placeholder implementations. These callbacks are where you integrate your existing purchase infrastructure. + +#### Your Implementation Requirements + +When the paywall calls your `performPurchase` handler: +1. Use the provided `Package` object to access product details and initiate your purchase flow +2. Process the transaction using your existing IAP infrastructure +3. **Call `Purchases.shared.syncPurchases()` (iOS) or `Purchases.sharedInstance.syncPurchases()` (Android) after successful purchases** - this is required for billing and analytics +4. Return the appropriate success/failure result + +When the paywall calls your `performRestore` handler: +1. Trigger your existing restore flow +2. **Call `syncPurchases()` after successful restores** +3. Return the appropriate success/failure result -### 8. Handle Purchases +#### Return Values -When a user interacts with purchase buttons in the paywall, you'll need to handle the transaction with your existing purchase infrastructure. +**iOS:** +- Success: `(userCancelled: false, error: nil)` +- User cancelled: `(userCancelled: true, error: nil)` +- Error: `(userCancelled: false, error: yourError)` -Your purchase handling code should: -- Detect when a user selects a product and initiates a purchase -- Process the purchase using your existing IAP implementation (StoreKit, Google Play Billing, etc.) -- Validate the receipt or transaction -- Update your internal subscription state -- Provide appropriate UI feedback to the user +**Android:** +- Success: `PurchaseLogicResult.Success` +- User cancelled: `PurchaseLogicResult.Cancellation` +- Error: `PurchaseLogicResult.Error(errorDetails: PurchasesError(...))` -The exact integration pattern may vary depending on your implementation. For details on available paywall events and listeners for your platform, see [Displaying Paywalls](/tools/paywalls/displaying-paywalls). +For more details on SDK configuration options, see [Using the SDK with your own IAP Code](/migrating-to-revenuecat/sdk-or-not/finishing-transactions). ## Using Experiments From 4d7c4f097f310dc19cc8876112eb7bcf644c158a Mon Sep 17 00:00:00 2001 From: Jeffrey Bunn Date: Thu, 6 Nov 2025 07:47:08 -0800 Subject: [PATCH 03/13] Update docs/tools/paywalls/custom-purchase-handling.mdx Co-authored-by: Chris Free --- docs/tools/paywalls/custom-purchase-handling.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tools/paywalls/custom-purchase-handling.mdx b/docs/tools/paywalls/custom-purchase-handling.mdx index bca580b99..bdae2ece5 100644 --- a/docs/tools/paywalls/custom-purchase-handling.mdx +++ b/docs/tools/paywalls/custom-purchase-handling.mdx @@ -37,7 +37,7 @@ import paywallsCustomAndroid2 from "@site/code_blocks/tools/paywalls_custom_purc # Paywalls with Your Own Purchase Infrastructure -[RevenueCat Paywalls](/tools/paywalls) can be integrated into your app **even if you have existing in-app purchase infrastructure** that you want to maintain. +[RevenueCat Paywalls](/tools/paywalls) can be integrated into your app **even if you have existing in-app purchase infrastructure** that you'd like to keep using. This approach is designed for enterprises that want to leverage **RevenueCat's remote paywall configuration and native rendering capabilities** while continuing to use their own purchase handling, receipt validation, and subscription management systems. From 2f7200ed7e28f33a3bc74ffe4e65b2f45ae03820 Mon Sep 17 00:00:00 2001 From: Jeffrey Bunn Date: Thu, 6 Nov 2025 07:47:24 -0800 Subject: [PATCH 04/13] Update docs/tools/paywalls/custom-purchase-handling.mdx Co-authored-by: Chris Free --- docs/tools/paywalls/custom-purchase-handling.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tools/paywalls/custom-purchase-handling.mdx b/docs/tools/paywalls/custom-purchase-handling.mdx index bdae2ece5..f87e3101b 100644 --- a/docs/tools/paywalls/custom-purchase-handling.mdx +++ b/docs/tools/paywalls/custom-purchase-handling.mdx @@ -45,7 +45,7 @@ With this integration option, RevenueCat provides the paywall user interface and ## What's Included -When using Paywalls with your own purchase infrastructure, you get access to: +When using Paywalls with your own purchase infrastructure, you get access to RevenueCat's: - [**Paywall Design Editor**](/tools/paywalls/creating-paywalls#building-paywalls) - Visual editor with templates and drag-and-drop components - [**Native Paywall Rendering**](/tools/paywalls/displaying-paywalls) - Native, cross-platform UI rendering with the RevenueCat UI SDK From ce51d085e322d9b716820fd0ebbe4c3882f27200 Mon Sep 17 00:00:00 2001 From: Jeffrey Bunn Date: Thu, 6 Nov 2025 07:51:05 -0800 Subject: [PATCH 05/13] Apply suggestions from code review Co-authored-by: Chris Free --- .../paywalls/custom-purchase-handling.mdx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/tools/paywalls/custom-purchase-handling.mdx b/docs/tools/paywalls/custom-purchase-handling.mdx index f87e3101b..563a45381 100644 --- a/docs/tools/paywalls/custom-purchase-handling.mdx +++ b/docs/tools/paywalls/custom-purchase-handling.mdx @@ -50,9 +50,9 @@ When using Paywalls with your own purchase infrastructure, you get access to Rev - [**Paywall Design Editor**](/tools/paywalls/creating-paywalls#building-paywalls) - Visual editor with templates and drag-and-drop components - [**Native Paywall Rendering**](/tools/paywalls/displaying-paywalls) - Native, cross-platform UI rendering with the RevenueCat UI SDK - [**Paywall Charts & Analytics**](/dashboard-and-metrics/charts/real-time-charts#coming-soon) - Insights into paywall performance and conversion metrics -- [**Experiments**](/tools/experiments-v1) - A/B/C/D testing capabilities for paywall and offer variations +- [**Experiments**](/tools/experiments-v1) - multivariate testing capabilities for paywall and offer variations - [**Targeting**](/tools/targeting) - Audience segmentation to serve different paywalls and offers to different users -- [**Customer Profile**](/dashboard-and-metrics/customer-profile) - Customer information and purchase history +- [**Customer Profile**](/dashboard-and-metrics/customer-profile) - Customer information and purchase history dashboard ## What You Provide @@ -61,11 +61,11 @@ Your application remains responsible for: - **Purchase Handling** - Transaction processing via StoreKit, Google Play Billing, or Web - **Receipt Validation** - Verifying purchases and managing subscription states - **Subscription Management** - Handling entitlements and subscription lifecycle events -- **Analytics** (Optional) - Tracking purchase events and user behavior in your existing analytics stack +- **Analytics** - Tracking purchase events and user behavior in your existing analytics stack ## How It Works -The integration follows a straightforward pattern: +The _paywalls-only_ integration follows a straightforward pattern: 1. Configure your products in the RevenueCat dashboard 2. Design your paywalls remotely using RevenueCat's Paywall Designer @@ -96,9 +96,9 @@ We strongly recommend setting up [Platform Server Notifications](/platform-resou - Set up [Google Play service credentials](/service-credentials/creating-play-service-credentials) to automatically import products and prices into RevenueCat - Ensure your Google Play product IDs match your imported products in the RevenueCat dashboard -**For web apps:** +**For Web apps:** -Create your web products in the appropriate payment processor's dashboard and then import them into RevenueCat. +Create your Web products in the appropriate payment processor's dashboard and then import them into RevenueCat. - [RevenueCat Web Billing](/web/web-billing/overview) - [Stripe Billing](/web/integrations/stripe) - [Paddle Billing](/web/integrations/paddle) @@ -142,7 +142,7 @@ For more details on this configuration option, including StoreKit version select ### 4. Identifying Users -If your app has user accounts or authentication, you can provide custom App User IDs when configuring the SDK. This allows you to track users across devices and platforms. +If your app has user accounts or authentication, you can provide RevenueCat with custom App User IDs when configuring the SDK. This allows you to track users across devices and platforms. Date: Thu, 6 Nov 2025 09:28:22 -0800 Subject: [PATCH 06/13] Combined initialization and identification sections/code --- ...paywalls_custom_config_anonymous_kotlin.kt | 5 + .../paywalls_custom_config_anonymous_objc.m | 4 + ...paywalls_custom_config_with_user_ios.swift | 5 + ...paywalls_custom_config_with_user_java.java | 13 +++ ...paywalls_custom_config_with_user_kotlin.kt | 6 + .../paywalls_custom_config_with_user_objc.m | 4 + .../paywalls/custom-purchase-handling.mdx | 107 ++++++------------ 7 files changed, 69 insertions(+), 75 deletions(-) create mode 100644 code_blocks/tools/paywalls_custom_config_anonymous_kotlin.kt create mode 100644 code_blocks/tools/paywalls_custom_config_anonymous_objc.m create mode 100644 code_blocks/tools/paywalls_custom_config_with_user_ios.swift create mode 100644 code_blocks/tools/paywalls_custom_config_with_user_java.java create mode 100644 code_blocks/tools/paywalls_custom_config_with_user_kotlin.kt create mode 100644 code_blocks/tools/paywalls_custom_config_with_user_objc.m diff --git a/code_blocks/tools/paywalls_custom_config_anonymous_kotlin.kt b/code_blocks/tools/paywalls_custom_config_anonymous_kotlin.kt new file mode 100644 index 000000000..320deaf24 --- /dev/null +++ b/code_blocks/tools/paywalls_custom_config_anonymous_kotlin.kt @@ -0,0 +1,5 @@ +Purchases.configure( + PurchasesConfiguration.Builder(this, ) + .purchasesAreCompletedBy(PurchasesAreCompletedBy.MY_APP) + .build() +) diff --git a/code_blocks/tools/paywalls_custom_config_anonymous_objc.m b/code_blocks/tools/paywalls_custom_config_anonymous_objc.m new file mode 100644 index 000000000..804487b73 --- /dev/null +++ b/code_blocks/tools/paywalls_custom_config_anonymous_objc.m @@ -0,0 +1,4 @@ +[RCPurchases configureWithAPIKey:@ + appUserID:nil + purchasesAreCompletedBy:RCPurchasesAreCompletedByMyApp + storeKitVersion:RCStoreKitVersion2]; diff --git a/code_blocks/tools/paywalls_custom_config_with_user_ios.swift b/code_blocks/tools/paywalls_custom_config_with_user_ios.swift new file mode 100644 index 000000000..4685c6ab4 --- /dev/null +++ b/code_blocks/tools/paywalls_custom_config_with_user_ios.swift @@ -0,0 +1,5 @@ +Purchases.configure( + with: .init(withAPIKey: "") + .with(appUserID: "") + .with(purchasesAreCompletedBy: .myApp, storeKitVersion: .storeKit2) +) diff --git a/code_blocks/tools/paywalls_custom_config_with_user_java.java b/code_blocks/tools/paywalls_custom_config_with_user_java.java new file mode 100644 index 000000000..1713e3594 --- /dev/null +++ b/code_blocks/tools/paywalls_custom_config_with_user_java.java @@ -0,0 +1,13 @@ +// If you're targeting only Google Play Store +public class MainApplication extends Application { + @Override + public void onCreate() { + super.onCreate(); + Purchases.configure( + new PurchasesConfiguration.Builder(this, ) + .appUserID() + .purchasesAreCompletedBy(PurchasesAreCompletedBy.MY_APP) + .build() + ); + } +} diff --git a/code_blocks/tools/paywalls_custom_config_with_user_kotlin.kt b/code_blocks/tools/paywalls_custom_config_with_user_kotlin.kt new file mode 100644 index 000000000..019058480 --- /dev/null +++ b/code_blocks/tools/paywalls_custom_config_with_user_kotlin.kt @@ -0,0 +1,6 @@ +Purchases.configure( + PurchasesConfiguration.Builder(this, ) + .appUserID() + .purchasesAreCompletedBy(PurchasesAreCompletedBy.MY_APP) + .build() +) diff --git a/code_blocks/tools/paywalls_custom_config_with_user_objc.m b/code_blocks/tools/paywalls_custom_config_with_user_objc.m new file mode 100644 index 000000000..7d396fff2 --- /dev/null +++ b/code_blocks/tools/paywalls_custom_config_with_user_objc.m @@ -0,0 +1,4 @@ +[RCPurchases configureWithAPIKey:@ + appUserID:@ + purchasesAreCompletedBy:RCPurchasesAreCompletedByMyApp + storeKitVersion:RCStoreKitVersion2]; diff --git a/docs/tools/paywalls/custom-purchase-handling.mdx b/docs/tools/paywalls/custom-purchase-handling.mdx index 563a45381..0e0787e6f 100644 --- a/docs/tools/paywalls/custom-purchase-handling.mdx +++ b/docs/tools/paywalls/custom-purchase-handling.mdx @@ -7,28 +7,15 @@ hidden: false import RCCodeBlock from "@site/src/components/RCCodeBlock"; -import om1 from "@site/code_blocks/migrating-to-revenuecat/observer-mode_1.swift?raw"; -import om2 from "@site/code_blocks/migrating-to-revenuecat/observer-mode_2.m?raw"; -import om4 from "@site/code_blocks/migrating-to-revenuecat/observer-mode_4.java?raw"; -import om5 from "@site/code_blocks/migrating-to-revenuecat/observer-mode_5.dart?raw"; -import om6 from "@site/code_blocks/migrating-to-revenuecat/observer-mode_6.cs?raw"; -import om7 from "@site/code_blocks/migrating-to-revenuecat/observer-mode_7.js.txt?raw"; - -import content9 from "@site/code_blocks/customers/user-ids_9.swift?raw"; -import content10 from "@site/code_blocks/customers/user-ids_10.m?raw"; -import content11 from "@site/code_blocks/customers/user-ids_11.kt?raw"; -import content12 from "@site/code_blocks/customers/user-ids_12.java?raw"; -import content13 from "@site/code_blocks/customers/user-ids_13.js.txt?raw"; -import content14 from "@site/code_blocks/customers/user-ids_14.js.txt?raw"; -import content15 from "@site/code_blocks/customers/user-ids_15.js.txt?raw"; -import contentCapacitor2 from "@site/code_blocks/customers/user-ids_configure_with_user_capacitor.ts.txt?raw"; -import content16 from "@site/code_blocks/customers/user-ids_16.cs?raw"; -import contentKmp from "@site/code_blocks/customers/user-ids_kmp.kts?raw"; - -import userIdIOS from "@site/code_blocks/customers/user-ids_9.swift?raw"; -import userIdAndroid from "@site/code_blocks/customers/user-ids_11.kt?raw"; -import userIdRN from "@site/code_blocks/customers/user-ids_13.js.txt?raw"; -import userIdFlutter from "@site/code_blocks/customers/user-ids_14.js.txt?raw"; +import anonymousIOS from "@site/code_blocks/migrating-to-revenuecat/observer-mode_1.swift?raw"; +import anonymousObjC from "@site/code_blocks/tools/paywalls_custom_config_anonymous_objc.m?raw"; +import anonymousKotlin from "@site/code_blocks/tools/paywalls_custom_config_anonymous_kotlin.kt?raw"; +import anonymousJava from "@site/code_blocks/migrating-to-revenuecat/observer-mode_4.java?raw"; + +import customConfigIOS from "@site/code_blocks/tools/paywalls_custom_config_with_user_ios.swift?raw"; +import customConfigObjC from "@site/code_blocks/tools/paywalls_custom_config_with_user_objc.m?raw"; +import customConfigKotlin from "@site/code_blocks/tools/paywalls_custom_config_with_user_kotlin.kt?raw"; +import customConfigJava from "@site/code_blocks/tools/paywalls_custom_config_with_user_java.java?raw"; import paywallsCustomIOS1 from "@site/code_blocks/tools/paywalls_custom_purchase_ios_1.swift?raw"; import paywallsCustomIOS2 from "@site/code_blocks/tools/paywalls_custom_purchase_ios_2.swift?raw"; @@ -108,94 +95,64 @@ Create your Web products in the appropriate payment processor's dashboard and th Configure the RevenueCat SDK with `purchasesAreCompletedBy` set to `.myApp`. This tells the SDK that your application will handle purchase transactions rather than RevenueCat. +If your app has user accounts or authentication, you can also provide custom app user IDs to track users across devices and platforms. For apps without authentication, RevenueCat will automatically generate anonymous user IDs. + +#### Anonymous Users + -For more details on this configuration option, including StoreKit version selection and platform-specific considerations, see [Using the SDK with your own IAP Code](/migrating-to-revenuecat/sdk-or-not/finishing-transactions). - -### 4. Identifying Users - -If your app has user accounts or authentication, you can provide RevenueCat with custom App User IDs when configuring the SDK. This allows you to track users across devices and platforms. +#### Identified Users -For apps without authentication, RevenueCat will automatically generate anonymous user IDs. Learn more about [identifying users](/customers/identifying-customers). +For more details, see: +- [Initializing the RevenueCat SDK](/getting-started/configuring-sdk#initialization) +- [Using the RevenueCat SDK with your own IAP Code](/migrating-to-revenuecat/sdk-or-not/finishing-transactions) +- [Identifying Customers](/customers/identifying-customers) -### 5. Create Offerings +### 4. Create Offerings [Create an Offering](/offerings/overview) in the RevenueCat dashboard. An _Offering_ is the selection of products that are "offered" to a user on your paywall, and are required for Paywalls, Experiments, and Targeting. -### 6. Design Your Paywall +### 5. Design Your Paywall Use the [Paywall Designer](/tools/paywalls/creating-paywalls) in the RevenueCat dashboard to create your paywall. You can start with a template or build from scratch using the component-based editor. @@ -205,7 +162,7 @@ Use the [Paywall Designer](/tools/paywalls/creating-paywalls) in the RevenueCat - **Localization** - Manage translations for multiple languages in one place - **Templates** - Start from scratch or with one of our professionally-designed templates -### 7. Display Paywalls +### 6. Display Paywalls First, ensure you've installed the RevenueCat UI SDK for your platform. This is a separate package from the core RevenueCat SDK and is required to display paywalls. See [Paywalls Installation](/tools/paywalls/installation) for platform-specific installation instructions. @@ -263,7 +220,7 @@ When using custom purchase handling, standard `PaywallListener` callbacks (like For complete documentation on displaying paywalls, see [Displaying Paywalls](/tools/paywalls/displaying-paywalls). -### 8. Implementing Purchase Handlers +### 7. Implementing Purchase Handlers In the paywall integration above, you provided `performPurchase` and `performRestore` callbacks with placeholder implementations. These callbacks are where you integrate your existing purchase infrastructure. From 7e04ab5695f8f1dcbe725e5b0bb6c70a43ca6c65 Mon Sep 17 00:00:00 2001 From: Jeffrey Bunn Date: Thu, 6 Nov 2025 10:00:31 -0800 Subject: [PATCH 07/13] Update docs/tools/paywalls/custom-purchase-handling.mdx Co-authored-by: Chris Free --- docs/tools/paywalls/custom-purchase-handling.mdx | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/tools/paywalls/custom-purchase-handling.mdx b/docs/tools/paywalls/custom-purchase-handling.mdx index 0e0787e6f..73adcbac3 100644 --- a/docs/tools/paywalls/custom-purchase-handling.mdx +++ b/docs/tools/paywalls/custom-purchase-handling.mdx @@ -89,7 +89,6 @@ Create your Web products in the appropriate payment processor's dashboard and th - [RevenueCat Web Billing](/web/web-billing/overview) - [Stripe Billing](/web/integrations/stripe) - [Paddle Billing](/web/integrations/paddle) -- Other payment processors ### 3. Configure the SDK From f4f8e393999a239877686d8d8e86377f4321075f Mon Sep 17 00:00:00 2001 From: Jeffrey Bunn Date: Thu, 6 Nov 2025 19:25:38 -0800 Subject: [PATCH 08/13] Various style, link, copy updates --- .../paywalls_custom_purchase_ios_2.swift | 3 +-- .../paywalls/custom-purchase-handling.mdx | 24 +++++++++---------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/code_blocks/tools/paywalls_custom_purchase_ios_2.swift b/code_blocks/tools/paywalls_custom_purchase_ios_2.swift index 2fa5938e3..9e851c165 100644 --- a/code_blocks/tools/paywalls_custom_purchase_ios_2.swift +++ b/code_blocks/tools/paywalls_custom_purchase_ios_2.swift @@ -5,8 +5,7 @@ import RevenueCatUI struct ContentView: View { var body: some View { YourAppContent() - .presentPaywallIfNeeded( - requiredEntitlementIdentifier: "pro", + .presentPaywall( myAppPurchaseLogic: MyAppPurchaseLogic( performPurchase: { package in do { diff --git a/docs/tools/paywalls/custom-purchase-handling.mdx b/docs/tools/paywalls/custom-purchase-handling.mdx index 0e0787e6f..e74ad04c5 100644 --- a/docs/tools/paywalls/custom-purchase-handling.mdx +++ b/docs/tools/paywalls/custom-purchase-handling.mdx @@ -37,7 +37,7 @@ When using Paywalls with your own purchase infrastructure, you get access to Rev - [**Paywall Design Editor**](/tools/paywalls/creating-paywalls#building-paywalls) - Visual editor with templates and drag-and-drop components - [**Native Paywall Rendering**](/tools/paywalls/displaying-paywalls) - Native, cross-platform UI rendering with the RevenueCat UI SDK - [**Paywall Charts & Analytics**](/dashboard-and-metrics/charts/real-time-charts#coming-soon) - Insights into paywall performance and conversion metrics -- [**Experiments**](/tools/experiments-v1) - multivariate testing capabilities for paywall and offer variations +- [**Experiments**](/tools/experiments-v1) - Multivariate testing capabilities for paywall and offer variations - [**Targeting**](/tools/targeting) - Audience segmentation to serve different paywalls and offers to different users - [**Customer Profile**](/dashboard-and-metrics/customer-profile) - Customer information and purchase history dashboard @@ -71,10 +71,10 @@ We strongly recommend setting up [Platform Server Notifications](/platform-resou ### 2. Configure Products -[Configure your products](/projects/configuring-products) in the RevenueCat dashboard to match your existing product SKUs. This allows the paywall designer to reference your products when building paywall layouts. +[Configure your products](/projects/configuring-products) in the RevenueCat dashboard to match your existing product SKUs. **For iOS apps:** -- Create your in-app purchases in [App Store Connect](/getting-started/entitlements/ios-products) +- Create your In-App Purchases in [App Store Connect](/getting-started/entitlements/ios-products) - Set up an [App Store Connect API key](/service-credentials/itunesconnect-app-specific-shared-secret/app-store-connect-api-key-configuration) to automatically import products and prices into RevenueCat - Ensure your App Store Connect product IDs match your imported products in the RevenueCat dashboard @@ -97,7 +97,7 @@ Configure the RevenueCat SDK with `purchasesAreCompletedBy` set to `.myApp`. Thi If your app has user accounts or authentication, you can also provide custom app user IDs to track users across devices and platforms. For apps without authentication, RevenueCat will automatically generate anonymous user IDs. -#### Anonymous Users +#### Anonymous User Configuration -#### Identified Users +#### Identified User Configuration Date: Thu, 20 Nov 2025 08:48:51 -0800 Subject: [PATCH 09/13] Updated iOS code sample --- .../paywalls_custom_purchase_ios_1.swift | 54 ++++++++++++++++--- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/code_blocks/tools/paywalls_custom_purchase_ios_1.swift b/code_blocks/tools/paywalls_custom_purchase_ios_1.swift index b2efd9fea..97121aa23 100644 --- a/code_blocks/tools/paywalls_custom_purchase_ios_1.swift +++ b/code_blocks/tools/paywalls_custom_purchase_ios_1.swift @@ -1,4 +1,5 @@ import SwiftUI +import StoreKit import RevenueCat import RevenueCatUI @@ -37,22 +38,59 @@ struct ContentView: View { } } + // MARK: - Your StoreKit 2 implementation here. Sample below: + private func performCustomPurchase(_ package: Package) async throws { - // Your custom purchase implementation - let transaction = try await package.storeProduct.purchase() + // 1. Get the StoreKit 2 Product from the package + let productId = package.storeProduct.productIdentifier + let products = try await Product.products(for: [productId]) + + guard let skProduct = products.first else { + throw NSError(domain: "ProductNotFound", code: 404) + } - // Validate with your server if needed - // try await validateWithYourServer(transaction) + // 2. Initiate purchase and handle the result + let result = try await skProduct.purchase() - // Sync with RevenueCat - await Purchases.shared.syncPurchases() + switch result { + case .success(let verificationResult): + // 3. Verify the transaction + let transaction = try checkVerified(verificationResult) + + // 4. Finish the transaction + await transaction.finish() + + // 5. Sync with RevenueCat + _ = try? await Purchases.shared.syncPurchases() + + case .userCancelled: + // User cancelled - don't throw, just return + break + + case .pending: + // Handle Ask to Buy or other pending scenarios + break + + @unknown default: + throw NSError(domain: "UnknownPurchaseResult", code: 500) + } } private func performCustomRestore() async throws { - // Restore all purchases + // Restore all purchases from the App Store try await AppStore.sync() // Sync with RevenueCat - await Purchases.shared.syncPurchases() + _ = try? await Purchases.shared.syncPurchases() + } + + // Helper to verify StoreKit 2 transactions + private func checkVerified(_ result: StoreKit.VerificationResult) throws -> T { + switch result { + case .unverified: + throw NSError(domain: "VerificationFailed", code: 403) + case .verified(let safe): + return safe + } } } From 6eed487ca235caf84b044b2ac8cc7aeb8c4da7b4 Mon Sep 17 00:00:00 2001 From: Jeffrey Bunn Date: Thu, 20 Nov 2025 08:52:33 -0800 Subject: [PATCH 10/13] Apply suggestions from code review Co-authored-by: Chris Free --- docs/tools/paywalls/custom-purchase-handling.mdx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/tools/paywalls/custom-purchase-handling.mdx b/docs/tools/paywalls/custom-purchase-handling.mdx index ad790d83c..03ba9948a 100644 --- a/docs/tools/paywalls/custom-purchase-handling.mdx +++ b/docs/tools/paywalls/custom-purchase-handling.mdx @@ -52,12 +52,12 @@ Your application remains responsible for: ## How It Works -The _paywalls-only_ integration follows a straightforward pattern: +Using RevenueCat Paywalls with your own purchase infrastructure follows a straightforward pattern: 1. Configure your products in the RevenueCat dashboard 2. Design your paywalls remotely using RevenueCat's Paywall Designer -3. The RevenueCat SDK fetches and renders paywalls natively in your app -4. When users interact with the paywall, your existing purchase infrastructure handles the transaction +3. Leverage the applicable RevenueCat SDK to fetch and render paywalls natively in your app +4. When users attempt a purchase, restore, etc. through the RevenueCat Paywall, your existing purchase infrastructure handles the transaction ## Setup @@ -142,7 +142,7 @@ If your app has user accounts or authentication, you can also provide custom app ]} /> -For more details, see: +**For more details, see:** - [Initializing the RevenueCat SDK](/getting-started/configuring-sdk#initialization) - [Using the RevenueCat SDK with your own IAP Code](/migrating-to-revenuecat/sdk-or-not/finishing-transactions) - [Identifying Customers](/customers/identifying-customers) @@ -228,12 +228,12 @@ In the paywall integration above, you provided `performPurchase` and `performRes When the paywall calls your `performPurchase` handler: 1. Use the provided `Package` object to access product details and initiate your purchase flow 2. Process the transaction using your existing IAP infrastructure -3. **Call `Purchases.shared.syncPurchases()` (iOS) or `Purchases.sharedInstance.syncPurchases()` (Android) after successful purchases** - this is required for billing and analytics +3. Call `Purchases.shared.syncPurchases()` (iOS) or `Purchases.sharedInstance.syncPurchases()` (Android) after successful purchases - this is required for billing and analytics 4. Return the appropriate success/failure result When the paywall calls your `performRestore` handler: 1. Trigger your existing restore flow -2. **Call `syncPurchases()` after successful restores** +2. Call `syncPurchases()` after successful restores 3. Return the appropriate success/failure result #### Return Values From 86fd325e2484a6d0af36b5c76df40d226597b242 Mon Sep 17 00:00:00 2001 From: Jeffrey Bunn Date: Thu, 20 Nov 2025 09:17:05 -0800 Subject: [PATCH 11/13] Updated Android code samples --- .../paywalls_custom_purchase_android_1.kt | 141 ++++++++++++- .../paywalls_custom_purchase_android_2.kt | 192 ++++++++++++++++-- 2 files changed, 303 insertions(+), 30 deletions(-) diff --git a/code_blocks/tools/paywalls_custom_purchase_android_1.kt b/code_blocks/tools/paywalls_custom_purchase_android_1.kt index 601833f82..b50adce12 100644 --- a/code_blocks/tools/paywalls_custom_purchase_android_1.kt +++ b/code_blocks/tools/paywalls_custom_purchase_android_1.kt @@ -1,5 +1,6 @@ import android.app.Activity import androidx.compose.runtime.* +import com.android.billingclient.api.* import com.revenuecat.purchases.CustomerInfo import com.revenuecat.purchases.Package import com.revenuecat.purchases.Purchases @@ -19,13 +20,10 @@ fun MyPaywallScreen() { rcPackage: Package, ): PurchaseLogicResult { return try { - // Your custom purchase implementation - // e.g., launch billing flow, validate with server - - // Sync with RevenueCat after successful purchase - Purchases.sharedInstance.syncPurchases() - + performCustomPurchase(activity, rcPackage) PurchaseLogicResult.Success + } catch (e: UserCancelledException) { + PurchaseLogicResult.Cancellation } catch (e: Exception) { PurchaseLogicResult.Error( errorDetails = PurchasesError( @@ -40,11 +38,7 @@ fun MyPaywallScreen() { customerInfo: CustomerInfo ): PurchaseLogicResult { return try { - // Your custom restore implementation - - // Sync with RevenueCat - Purchases.sharedInstance.syncPurchases() - + performCustomRestore() PurchaseLogicResult.Success } catch (e: Exception) { PurchaseLogicResult.Error() @@ -61,3 +55,128 @@ fun MyPaywallScreen() { Paywall(options = paywallOptions) } + +// MARK: - Your Billing Client 8 implementation here. Sample below: + +class UserCancelledException : Exception() + +private suspend fun performCustomPurchase( + activity: Activity, + rcPackage: Package +): Unit = kotlinx.coroutines.suspendCancellableCoroutine { continuation -> + // 1. Initialize Billing Client + val billingClient = BillingClient.newBuilder(activity) + .setListener { billingResult, purchases -> + // Handle purchase updates + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { + purchases.forEach { purchase -> + if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { + // Acknowledge purchase if needed + if (!purchase.isAcknowledged) { + val params = AcknowledgePurchaseParams.newBuilder() + .setPurchaseToken(purchase.purchaseToken) + .build() + billingClient.acknowledgePurchase(params) { } + } + } + } + } + } + .enablePendingPurchases() + .build() + + // 2. Connect to Billing Client + billingClient.startConnection(object : BillingClientStateListener { + override fun onBillingSetupFinished(billingResult: BillingResult) { + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + // 3. Query product details + val product = QueryProductDetailsParams.Product.newBuilder() + .setProductId(rcPackage.product.id) + .setProductType(BillingClient.ProductType.SUBS) + .build() + + val params = QueryProductDetailsParams.newBuilder() + .setProductList(listOf(product)) + .build() + + billingClient.queryProductDetailsAsync(params) { result, productDetailsList -> + if (result.responseCode == BillingClient.BillingResponseCode.OK) { + val productDetails = productDetailsList.firstOrNull() + if (productDetails != null) { + // 4. Launch billing flow + val offerToken = productDetails.subscriptionOfferDetails?.firstOrNull()?.offerToken + val productDetailsParams = BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(productDetails) + .setOfferToken(offerToken ?: "") + .build() + + val flowParams = BillingFlowParams.newBuilder() + .setProductDetailsParamsList(listOf(productDetailsParams)) + .build() + + val flowResult = billingClient.launchBillingFlow(activity, flowParams) + + when (flowResult.responseCode) { + BillingClient.BillingResponseCode.OK -> { + // 5. Sync with RevenueCat + Purchases.sharedInstance.syncPurchases() + continuation.resume(Unit) { } + } + BillingClient.BillingResponseCode.USER_CANCELED -> { + continuation.resumeWith(Result.failure(UserCancelledException())) + } + else -> { + continuation.resumeWith(Result.failure(Exception(flowResult.debugMessage))) + } + } + } + } + } + } + } + + override fun onBillingServiceDisconnected() { + // Retry connection if needed + } + }) +} + +private suspend fun performCustomRestore() = kotlinx.coroutines.suspendCancellableCoroutine { continuation -> + // 1. Initialize Billing Client + val billingClient = BillingClient.newBuilder(/* context */) + .setListener { _, _ -> } + .enablePendingPurchases() + .build() + + // 2. Connect and query existing purchases + billingClient.startConnection(object : BillingClientStateListener { + override fun onBillingSetupFinished(billingResult: BillingResult) { + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + val params = QueryPurchasesParams.newBuilder() + .setProductType(BillingClient.ProductType.SUBS) + .build() + + billingClient.queryPurchasesAsync(params) { result, purchases -> + if (result.responseCode == BillingClient.BillingResponseCode.OK) { + // 3. Acknowledge any unacknowledged purchases + purchases.forEach { purchase -> + if (!purchase.isAcknowledged && + purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { + val ackParams = AcknowledgePurchaseParams.newBuilder() + .setPurchaseToken(purchase.purchaseToken) + .build() + billingClient.acknowledgePurchase(ackParams) { } + } + } + + // 4. Sync with RevenueCat + Purchases.sharedInstance.syncPurchases() + continuation.resume(Unit) { } + } + } + } + } + + override fun onBillingServiceDisconnected() { } + }) +} diff --git a/code_blocks/tools/paywalls_custom_purchase_android_2.kt b/code_blocks/tools/paywalls_custom_purchase_android_2.kt index 3ba780354..c1c829de8 100644 --- a/code_blocks/tools/paywalls_custom_purchase_android_2.kt +++ b/code_blocks/tools/paywalls_custom_purchase_android_2.kt @@ -1,6 +1,7 @@ import android.app.Activity import android.os.Bundle import androidx.appcompat.app.AppCompatActivity +import com.android.billingclient.api.* import com.revenuecat.purchases.CustomerInfo import com.revenuecat.purchases.Package import com.revenuecat.purchases.Purchases @@ -20,28 +21,18 @@ class MyActivity : AppCompatActivity() { val paywallView = findViewById(R.id.paywallView) + // Note: For new projects, we recommend using the Compose + suspend function approach + // (see paywalls_custom_purchase_android_1.kt). This callback-based approach is + // provided for legacy codebases. + val myPurchaseLogic = object : PurchaseLogicWithCallback() { override fun performPurchaseWithCompletion( activity: Activity, rcPackage: Package, completion: (PurchaseLogicResult) -> Unit, ) { - try { - // Your custom purchase implementation - - // Sync with RevenueCat - Purchases.sharedInstance.syncPurchases() - - completion(PurchaseLogicResult.Success) - } catch (e: Exception) { - completion( - PurchaseLogicResult.Error( - errorDetails = PurchasesError( - code = PurchasesErrorCode.PurchaseInvalidError, - underlyingErrorMessage = e.message - ) - ) - ) + performCustomPurchase(activity, rcPackage) { result -> + completion(result) } } @@ -49,9 +40,9 @@ class MyActivity : AppCompatActivity() { customerInfo: CustomerInfo, completion: (PurchaseLogicResult) -> Unit, ) { - // Your custom restore implementation - Purchases.sharedInstance.syncPurchases() - completion(PurchaseLogicResult.Success) + performCustomRestore { result -> + completion(result) + } } } @@ -63,4 +54,167 @@ class MyActivity : AppCompatActivity() { paywallView.setPaywallOptions(paywallOptions) } + + // MARK: - Your Billing Client 8 implementation here. Sample below: + + private fun performCustomPurchase( + activity: Activity, + rcPackage: Package, + completion: (PurchaseLogicResult) -> Unit + ) { + // 1. Initialize and connect to Billing Client + val billingClient = BillingClient.newBuilder(activity) + .setListener { billingResult, purchases -> + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { + purchases.forEach { purchase -> + if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { + // Acknowledge purchase + if (!purchase.isAcknowledged) { + val params = AcknowledgePurchaseParams.newBuilder() + .setPurchaseToken(purchase.purchaseToken) + .build() + billingClient.acknowledgePurchase(params) { } + } + } + } + } + } + .enablePendingPurchases() + .build() + + billingClient.startConnection(object : BillingClientStateListener { + override fun onBillingSetupFinished(billingResult: BillingResult) { + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + // 2. Query product details + val product = QueryProductDetailsParams.Product.newBuilder() + .setProductId(rcPackage.product.id) + .setProductType(BillingClient.ProductType.SUBS) + .build() + + val params = QueryProductDetailsParams.newBuilder() + .setProductList(listOf(product)) + .build() + + billingClient.queryProductDetailsAsync(params) { result, productDetailsList -> + val productDetails = productDetailsList.firstOrNull() + if (result.responseCode == BillingClient.BillingResponseCode.OK && productDetails != null) { + // 3. Launch billing flow + val offerToken = productDetails.subscriptionOfferDetails?.firstOrNull()?.offerToken + val productDetailsParams = BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(productDetails) + .setOfferToken(offerToken ?: "") + .build() + + val flowParams = BillingFlowParams.newBuilder() + .setProductDetailsParamsList(listOf(productDetailsParams)) + .build() + + val flowResult = billingClient.launchBillingFlow(activity, flowParams) + + when (flowResult.responseCode) { + BillingClient.BillingResponseCode.OK -> { + // 4. Sync with RevenueCat + Purchases.sharedInstance.syncPurchases() + completion(PurchaseLogicResult.Success) + } + BillingClient.BillingResponseCode.USER_CANCELED -> { + completion(PurchaseLogicResult.Cancellation) + } + else -> { + completion( + PurchaseLogicResult.Error( + errorDetails = PurchasesError( + code = PurchasesErrorCode.PurchaseInvalidError, + underlyingErrorMessage = flowResult.debugMessage + ) + ) + ) + } + } + } else { + completion( + PurchaseLogicResult.Error( + errorDetails = PurchasesError( + code = PurchasesErrorCode.ProductNotAvailableForPurchaseError, + underlyingErrorMessage = "Product not found" + ) + ) + ) + } + } + } else { + completion( + PurchaseLogicResult.Error( + errorDetails = PurchasesError( + code = PurchasesErrorCode.StoreProblemError, + underlyingErrorMessage = "Failed to connect to billing service" + ) + ) + ) + } + } + + override fun onBillingServiceDisconnected() { + // Retry connection if needed + } + }) + } + + private fun performCustomRestore(completion: (PurchaseLogicResult) -> Unit) { + // 1. Initialize and connect to Billing Client + val billingClient = BillingClient.newBuilder(this) + .setListener { _, _ -> } + .enablePendingPurchases() + .build() + + billingClient.startConnection(object : BillingClientStateListener { + override fun onBillingSetupFinished(billingResult: BillingResult) { + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + // 2. Query existing purchases + val params = QueryPurchasesParams.newBuilder() + .setProductType(BillingClient.ProductType.SUBS) + .build() + + billingClient.queryPurchasesAsync(params) { result, purchases -> + if (result.responseCode == BillingClient.BillingResponseCode.OK) { + // 3. Acknowledge any unacknowledged purchases + purchases.forEach { purchase -> + if (!purchase.isAcknowledged && + purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { + val ackParams = AcknowledgePurchaseParams.newBuilder() + .setPurchaseToken(purchase.purchaseToken) + .build() + billingClient.acknowledgePurchase(ackParams) { } + } + } + + // 4. Sync with RevenueCat + Purchases.sharedInstance.syncPurchases() + completion(PurchaseLogicResult.Success) + } else { + completion( + PurchaseLogicResult.Error( + errorDetails = PurchasesError( + code = PurchasesErrorCode.StoreProblemError, + underlyingErrorMessage = "Failed to query purchases" + ) + ) + ) + } + } + } else { + completion( + PurchaseLogicResult.Error( + errorDetails = PurchasesError( + code = PurchasesErrorCode.StoreProblemError, + underlyingErrorMessage = "Failed to connect to billing service" + ) + ) + ) + } + } + + override fun onBillingServiceDisconnected() { } + }) + } } From d85bafd801d1ebd8dacd8274127657323677e910 Mon Sep 17 00:00:00 2001 From: Jeffrey Bunn Date: Tue, 25 Nov 2025 08:28:23 -0800 Subject: [PATCH 12/13] Removed storekit/billing client comprehensive code samples; stubs only --- .../paywalls_custom_config_anonymous_objc.m | 2 +- .../paywalls_custom_config_with_user_objc.m | 4 +- .../paywalls_custom_purchase_android_1.kt | 128 ++------------ .../paywalls_custom_purchase_android_2.kt | 167 ++---------------- .../paywalls_custom_purchase_ios_1.swift | 54 +----- .../paywalls_custom_purchase_ios_2.swift | 19 +- 6 files changed, 49 insertions(+), 325 deletions(-) diff --git a/code_blocks/tools/paywalls_custom_config_anonymous_objc.m b/code_blocks/tools/paywalls_custom_config_anonymous_objc.m index 804487b73..8fdf9545a 100644 --- a/code_blocks/tools/paywalls_custom_config_anonymous_objc.m +++ b/code_blocks/tools/paywalls_custom_config_anonymous_objc.m @@ -1,4 +1,4 @@ -[RCPurchases configureWithAPIKey:@ +[RCPurchases configureWithAPIKey:@"" appUserID:nil purchasesAreCompletedBy:RCPurchasesAreCompletedByMyApp storeKitVersion:RCStoreKitVersion2]; diff --git a/code_blocks/tools/paywalls_custom_config_with_user_objc.m b/code_blocks/tools/paywalls_custom_config_with_user_objc.m index 7d396fff2..a4b5fc932 100644 --- a/code_blocks/tools/paywalls_custom_config_with_user_objc.m +++ b/code_blocks/tools/paywalls_custom_config_with_user_objc.m @@ -1,4 +1,4 @@ -[RCPurchases configureWithAPIKey:@ - appUserID:@ +[RCPurchases configureWithAPIKey:@"" + appUserID:@"" purchasesAreCompletedBy:RCPurchasesAreCompletedByMyApp storeKitVersion:RCStoreKitVersion2]; diff --git a/code_blocks/tools/paywalls_custom_purchase_android_1.kt b/code_blocks/tools/paywalls_custom_purchase_android_1.kt index b50adce12..882083cce 100644 --- a/code_blocks/tools/paywalls_custom_purchase_android_1.kt +++ b/code_blocks/tools/paywalls_custom_purchase_android_1.kt @@ -1,6 +1,5 @@ import android.app.Activity import androidx.compose.runtime.* -import com.android.billingclient.api.* import com.revenuecat.purchases.CustomerInfo import com.revenuecat.purchases.Package import com.revenuecat.purchases.Purchases @@ -10,6 +9,7 @@ import com.revenuecat.purchases.ui.revenuecatui.Paywall import com.revenuecat.purchases.ui.revenuecatui.PaywallOptions import com.revenuecat.purchases.ui.revenuecatui.PurchaseLogic import com.revenuecat.purchases.ui.revenuecatui.PurchaseLogicResult +import kotlinx.coroutines.suspendCancellableCoroutine @Composable fun MyPaywallScreen() { @@ -56,127 +56,27 @@ fun MyPaywallScreen() { Paywall(options = paywallOptions) } -// MARK: - Your Billing Client 8 implementation here. Sample below: +// MARK: - Your Billing Client Implementation class UserCancelledException : Exception() private suspend fun performCustomPurchase( activity: Activity, rcPackage: Package -): Unit = kotlinx.coroutines.suspendCancellableCoroutine { continuation -> - // 1. Initialize Billing Client - val billingClient = BillingClient.newBuilder(activity) - .setListener { billingResult, purchases -> - // Handle purchase updates - if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { - purchases.forEach { purchase -> - if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { - // Acknowledge purchase if needed - if (!purchase.isAcknowledged) { - val params = AcknowledgePurchaseParams.newBuilder() - .setPurchaseToken(purchase.purchaseToken) - .build() - billingClient.acknowledgePurchase(params) { } - } - } - } - } - } - .enablePendingPurchases() - .build() - - // 2. Connect to Billing Client - billingClient.startConnection(object : BillingClientStateListener { - override fun onBillingSetupFinished(billingResult: BillingResult) { - if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - // 3. Query product details - val product = QueryProductDetailsParams.Product.newBuilder() - .setProductId(rcPackage.product.id) - .setProductType(BillingClient.ProductType.SUBS) - .build() - - val params = QueryProductDetailsParams.newBuilder() - .setProductList(listOf(product)) - .build() +): Unit = suspendCancellableCoroutine { continuation -> + // Implement your Billing Client purchase flow here. + // See Google's documentation: https://developer.android.com/google/play/billing/integrate - billingClient.queryProductDetailsAsync(params) { result, productDetailsList -> - if (result.responseCode == BillingClient.BillingResponseCode.OK) { - val productDetails = productDetailsList.firstOrNull() - if (productDetails != null) { - // 4. Launch billing flow - val offerToken = productDetails.subscriptionOfferDetails?.firstOrNull()?.offerToken - val productDetailsParams = BillingFlowParams.ProductDetailsParams.newBuilder() - .setProductDetails(productDetails) - .setOfferToken(offerToken ?: "") - .build() - - val flowParams = BillingFlowParams.newBuilder() - .setProductDetailsParamsList(listOf(productDetailsParams)) - .build() - - val flowResult = billingClient.launchBillingFlow(activity, flowParams) - - when (flowResult.responseCode) { - BillingClient.BillingResponseCode.OK -> { - // 5. Sync with RevenueCat - Purchases.sharedInstance.syncPurchases() - continuation.resume(Unit) { } - } - BillingClient.BillingResponseCode.USER_CANCELED -> { - continuation.resumeWith(Result.failure(UserCancelledException())) - } - else -> { - continuation.resumeWith(Result.failure(Exception(flowResult.debugMessage))) - } - } - } - } - } - } - } - - override fun onBillingServiceDisconnected() { - // Retry connection if needed - } - }) + // Sync with RevenueCat after purchase completes + // Purchases.sharedInstance.syncPurchases() + // continuation.resume(Unit) { } } -private suspend fun performCustomRestore() = kotlinx.coroutines.suspendCancellableCoroutine { continuation -> - // 1. Initialize Billing Client - val billingClient = BillingClient.newBuilder(/* context */) - .setListener { _, _ -> } - .enablePendingPurchases() - .build() - - // 2. Connect and query existing purchases - billingClient.startConnection(object : BillingClientStateListener { - override fun onBillingSetupFinished(billingResult: BillingResult) { - if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - val params = QueryPurchasesParams.newBuilder() - .setProductType(BillingClient.ProductType.SUBS) - .build() - - billingClient.queryPurchasesAsync(params) { result, purchases -> - if (result.responseCode == BillingClient.BillingResponseCode.OK) { - // 3. Acknowledge any unacknowledged purchases - purchases.forEach { purchase -> - if (!purchase.isAcknowledged && - purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { - val ackParams = AcknowledgePurchaseParams.newBuilder() - .setPurchaseToken(purchase.purchaseToken) - .build() - billingClient.acknowledgePurchase(ackParams) { } - } - } +private suspend fun performCustomRestore(): Unit = suspendCancellableCoroutine { continuation -> + // Implement your restore flow here. + // See: https://developer.android.com/google/play/billing/integrate#pending - // 4. Sync with RevenueCat - Purchases.sharedInstance.syncPurchases() - continuation.resume(Unit) { } - } - } - } - } - - override fun onBillingServiceDisconnected() { } - }) + // Sync with RevenueCat after restore completes + // Purchases.sharedInstance.syncPurchases() + // continuation.resume(Unit) { } } diff --git a/code_blocks/tools/paywalls_custom_purchase_android_2.kt b/code_blocks/tools/paywalls_custom_purchase_android_2.kt index c1c829de8..c5f5c1176 100644 --- a/code_blocks/tools/paywalls_custom_purchase_android_2.kt +++ b/code_blocks/tools/paywalls_custom_purchase_android_2.kt @@ -1,7 +1,6 @@ import android.app.Activity import android.os.Bundle import androidx.appcompat.app.AppCompatActivity -import com.android.billingclient.api.* import com.revenuecat.purchases.CustomerInfo import com.revenuecat.purchases.Package import com.revenuecat.purchases.Purchases @@ -21,9 +20,8 @@ class MyActivity : AppCompatActivity() { val paywallView = findViewById(R.id.paywallView) - // Note: For new projects, we recommend using the Compose + suspend function approach - // (see paywalls_custom_purchase_android_1.kt). This callback-based approach is - // provided for legacy codebases. + // Note: For new projects, we recommend using the Compose + suspend function approach. + // This callback-based approach is provided for legacy codebases. val myPurchaseLogic = object : PurchaseLogicWithCallback() { override fun performPurchaseWithCompletion( @@ -55,166 +53,27 @@ class MyActivity : AppCompatActivity() { paywallView.setPaywallOptions(paywallOptions) } - // MARK: - Your Billing Client 8 implementation here. Sample below: + // MARK: - Your Billing Client Implementation private fun performCustomPurchase( activity: Activity, rcPackage: Package, completion: (PurchaseLogicResult) -> Unit ) { - // 1. Initialize and connect to Billing Client - val billingClient = BillingClient.newBuilder(activity) - .setListener { billingResult, purchases -> - if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { - purchases.forEach { purchase -> - if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { - // Acknowledge purchase - if (!purchase.isAcknowledged) { - val params = AcknowledgePurchaseParams.newBuilder() - .setPurchaseToken(purchase.purchaseToken) - .build() - billingClient.acknowledgePurchase(params) { } - } - } - } - } - } - .enablePendingPurchases() - .build() - - billingClient.startConnection(object : BillingClientStateListener { - override fun onBillingSetupFinished(billingResult: BillingResult) { - if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - // 2. Query product details - val product = QueryProductDetailsParams.Product.newBuilder() - .setProductId(rcPackage.product.id) - .setProductType(BillingClient.ProductType.SUBS) - .build() - - val params = QueryProductDetailsParams.newBuilder() - .setProductList(listOf(product)) - .build() - - billingClient.queryProductDetailsAsync(params) { result, productDetailsList -> - val productDetails = productDetailsList.firstOrNull() - if (result.responseCode == BillingClient.BillingResponseCode.OK && productDetails != null) { - // 3. Launch billing flow - val offerToken = productDetails.subscriptionOfferDetails?.firstOrNull()?.offerToken - val productDetailsParams = BillingFlowParams.ProductDetailsParams.newBuilder() - .setProductDetails(productDetails) - .setOfferToken(offerToken ?: "") - .build() - - val flowParams = BillingFlowParams.newBuilder() - .setProductDetailsParamsList(listOf(productDetailsParams)) - .build() - - val flowResult = billingClient.launchBillingFlow(activity, flowParams) - - when (flowResult.responseCode) { - BillingClient.BillingResponseCode.OK -> { - // 4. Sync with RevenueCat - Purchases.sharedInstance.syncPurchases() - completion(PurchaseLogicResult.Success) - } - BillingClient.BillingResponseCode.USER_CANCELED -> { - completion(PurchaseLogicResult.Cancellation) - } - else -> { - completion( - PurchaseLogicResult.Error( - errorDetails = PurchasesError( - code = PurchasesErrorCode.PurchaseInvalidError, - underlyingErrorMessage = flowResult.debugMessage - ) - ) - ) - } - } - } else { - completion( - PurchaseLogicResult.Error( - errorDetails = PurchasesError( - code = PurchasesErrorCode.ProductNotAvailableForPurchaseError, - underlyingErrorMessage = "Product not found" - ) - ) - ) - } - } - } else { - completion( - PurchaseLogicResult.Error( - errorDetails = PurchasesError( - code = PurchasesErrorCode.StoreProblemError, - underlyingErrorMessage = "Failed to connect to billing service" - ) - ) - ) - } - } + // Implement your Billing Client purchase flow here. + // See Google's documentation: https://developer.android.com/google/play/billing/integrate - override fun onBillingServiceDisconnected() { - // Retry connection if needed - } - }) + // Sync with RevenueCat after purchase completes + // Purchases.sharedInstance.syncPurchases() + // completion(PurchaseLogicResult.Success) } private fun performCustomRestore(completion: (PurchaseLogicResult) -> Unit) { - // 1. Initialize and connect to Billing Client - val billingClient = BillingClient.newBuilder(this) - .setListener { _, _ -> } - .enablePendingPurchases() - .build() - - billingClient.startConnection(object : BillingClientStateListener { - override fun onBillingSetupFinished(billingResult: BillingResult) { - if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - // 2. Query existing purchases - val params = QueryPurchasesParams.newBuilder() - .setProductType(BillingClient.ProductType.SUBS) - .build() + // Implement your restore flow here. + // See: https://developer.android.com/google/play/billing/integrate#pending - billingClient.queryPurchasesAsync(params) { result, purchases -> - if (result.responseCode == BillingClient.BillingResponseCode.OK) { - // 3. Acknowledge any unacknowledged purchases - purchases.forEach { purchase -> - if (!purchase.isAcknowledged && - purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { - val ackParams = AcknowledgePurchaseParams.newBuilder() - .setPurchaseToken(purchase.purchaseToken) - .build() - billingClient.acknowledgePurchase(ackParams) { } - } - } - - // 4. Sync with RevenueCat - Purchases.sharedInstance.syncPurchases() - completion(PurchaseLogicResult.Success) - } else { - completion( - PurchaseLogicResult.Error( - errorDetails = PurchasesError( - code = PurchasesErrorCode.StoreProblemError, - underlyingErrorMessage = "Failed to query purchases" - ) - ) - ) - } - } - } else { - completion( - PurchaseLogicResult.Error( - errorDetails = PurchasesError( - code = PurchasesErrorCode.StoreProblemError, - underlyingErrorMessage = "Failed to connect to billing service" - ) - ) - ) - } - } - - override fun onBillingServiceDisconnected() { } - }) + // Sync with RevenueCat after restore completes + // Purchases.sharedInstance.syncPurchases() + // completion(PurchaseLogicResult.Success) } } diff --git a/code_blocks/tools/paywalls_custom_purchase_ios_1.swift b/code_blocks/tools/paywalls_custom_purchase_ios_1.swift index 97121aa23..86239abad 100644 --- a/code_blocks/tools/paywalls_custom_purchase_ios_1.swift +++ b/code_blocks/tools/paywalls_custom_purchase_ios_1.swift @@ -38,59 +38,21 @@ struct ContentView: View { } } - // MARK: - Your StoreKit 2 implementation here. Sample below: + // MARK: - Your StoreKit Implementation private func performCustomPurchase(_ package: Package) async throws { - // 1. Get the StoreKit 2 Product from the package - let productId = package.storeProduct.productIdentifier - let products = try await Product.products(for: [productId]) + // Implement your StoreKit purchase flow here. + // See Apple's documentation: https://developer.apple.com/documentation/storekit/in-app-purchase - guard let skProduct = products.first else { - throw NSError(domain: "ProductNotFound", code: 404) - } - - // 2. Initiate purchase and handle the result - let result = try await skProduct.purchase() - - switch result { - case .success(let verificationResult): - // 3. Verify the transaction - let transaction = try checkVerified(verificationResult) - - // 4. Finish the transaction - await transaction.finish() - - // 5. Sync with RevenueCat - _ = try? await Purchases.shared.syncPurchases() - - case .userCancelled: - // User cancelled - don't throw, just return - break - - case .pending: - // Handle Ask to Buy or other pending scenarios - break - - @unknown default: - throw NSError(domain: "UnknownPurchaseResult", code: 500) - } + // Sync with RevenueCat after purchase completes + _ = try? await Purchases.shared.syncPurchases() } private func performCustomRestore() async throws { - // Restore all purchases from the App Store - try await AppStore.sync() + // Implement your restore flow here. + // See: https://developer.apple.com/documentation/storekit/transaction/currententitlements - // Sync with RevenueCat + // Sync with RevenueCat after restore completes _ = try? await Purchases.shared.syncPurchases() } - - // Helper to verify StoreKit 2 transactions - private func checkVerified(_ result: StoreKit.VerificationResult) throws -> T { - switch result { - case .unverified: - throw NSError(domain: "VerificationFailed", code: 403) - case .verified(let safe): - return safe - } - } } diff --git a/code_blocks/tools/paywalls_custom_purchase_ios_2.swift b/code_blocks/tools/paywalls_custom_purchase_ios_2.swift index 9e851c165..bf2f085ce 100644 --- a/code_blocks/tools/paywalls_custom_purchase_ios_2.swift +++ b/code_blocks/tools/paywalls_custom_purchase_ios_2.swift @@ -1,4 +1,5 @@ import SwiftUI +import StoreKit import RevenueCat import RevenueCatUI @@ -30,19 +31,21 @@ struct ContentView: View { ) } + // MARK: - Your StoreKit Implementation + private func performCustomPurchase(_ package: Package) async throws { - // Your custom purchase implementation - let transaction = try await package.storeProduct.purchase() + // Implement your StoreKit purchase flow here. + // See Apple's documentation: https://developer.apple.com/documentation/storekit/in-app-purchase - // Sync with RevenueCat - await Purchases.shared.syncPurchases() + // Sync with RevenueCat after purchase completes + _ = try? await Purchases.shared.syncPurchases() } private func performCustomRestore() async throws { - // Restore all purchases - try await AppStore.sync() + // Implement your restore flow here. + // See: https://developer.apple.com/documentation/storekit/transaction/currententitlements - // Sync with RevenueCat - await Purchases.shared.syncPurchases() + // Sync with RevenueCat after restore completes + _ = try? await Purchases.shared.syncPurchases() } } From a5c9f9ecae74bdc7625f0655eb02f0ebdcb89cf8 Mon Sep 17 00:00:00 2001 From: Jeffrey Bunn Date: Wed, 26 Nov 2025 10:56:42 -0800 Subject: [PATCH 13/13] Apply suggestions from code review Co-authored-by: Antonio Pallares --- code_blocks/tools/paywalls_custom_purchase_android_1.kt | 8 ++++---- code_blocks/tools/paywalls_custom_purchase_android_2.kt | 8 ++++---- docs/tools/paywalls/custom-purchase-handling.mdx | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/code_blocks/tools/paywalls_custom_purchase_android_1.kt b/code_blocks/tools/paywalls_custom_purchase_android_1.kt index 882083cce..d9095148b 100644 --- a/code_blocks/tools/paywalls_custom_purchase_android_1.kt +++ b/code_blocks/tools/paywalls_custom_purchase_android_1.kt @@ -68,8 +68,8 @@ private suspend fun performCustomPurchase( // See Google's documentation: https://developer.android.com/google/play/billing/integrate // Sync with RevenueCat after purchase completes - // Purchases.sharedInstance.syncPurchases() - // continuation.resume(Unit) { } + Purchases.sharedInstance.syncPurchases() + continuation.resume(Unit) { } } private suspend fun performCustomRestore(): Unit = suspendCancellableCoroutine { continuation -> @@ -77,6 +77,6 @@ private suspend fun performCustomRestore(): Unit = suspendCancellableCoroutine { // See: https://developer.android.com/google/play/billing/integrate#pending // Sync with RevenueCat after restore completes - // Purchases.sharedInstance.syncPurchases() - // continuation.resume(Unit) { } + Purchases.sharedInstance.syncPurchases() + continuation.resume(Unit) { } } diff --git a/code_blocks/tools/paywalls_custom_purchase_android_2.kt b/code_blocks/tools/paywalls_custom_purchase_android_2.kt index c5f5c1176..c8a2f2520 100644 --- a/code_blocks/tools/paywalls_custom_purchase_android_2.kt +++ b/code_blocks/tools/paywalls_custom_purchase_android_2.kt @@ -64,8 +64,8 @@ class MyActivity : AppCompatActivity() { // See Google's documentation: https://developer.android.com/google/play/billing/integrate // Sync with RevenueCat after purchase completes - // Purchases.sharedInstance.syncPurchases() - // completion(PurchaseLogicResult.Success) + Purchases.sharedInstance.syncPurchases() + completion(PurchaseLogicResult.Success) } private fun performCustomRestore(completion: (PurchaseLogicResult) -> Unit) { @@ -73,7 +73,7 @@ class MyActivity : AppCompatActivity() { // See: https://developer.android.com/google/play/billing/integrate#pending // Sync with RevenueCat after restore completes - // Purchases.sharedInstance.syncPurchases() - // completion(PurchaseLogicResult.Success) + Purchases.sharedInstance.syncPurchases() + completion(PurchaseLogicResult.Success) } } diff --git a/docs/tools/paywalls/custom-purchase-handling.mdx b/docs/tools/paywalls/custom-purchase-handling.mdx index 03ba9948a..5c67d8675 100644 --- a/docs/tools/paywalls/custom-purchase-handling.mdx +++ b/docs/tools/paywalls/custom-purchase-handling.mdx @@ -221,7 +221,7 @@ For complete documentation on displaying paywalls, see [Displaying Paywalls](/to ### 7. Implementing Purchase Handlers -In the paywall integration above, you provided `performPurchase` and `performRestore` callbacks with placeholder implementations. These callbacks are where you integrate your existing purchase infrastructure. +In the paywall integration above, you were provided with `performPurchase` and `performRestore` callbacks with placeholder implementations. These callbacks are where you integrate your existing purchase infrastructure. #### Your Implementation Requirements