---
id: "tools/paywalls/displaying-paywalls"
title: "Displaying Paywalls"
description: "When you enable paywall analytics integrations, user interactions with paywall controls can generate paywall_component_interacted events (tabs, packages, purchase buttons, sheets, etc.). Field names and platform notes are summarized in Paywall component interaction events."
permalink: "/docs/tools/paywalls/displaying-paywalls"
slug: "displaying-paywalls"
version: "current"
original_source: "docs/tools/paywalls/displaying-paywalls.mdx"
---

> **AI agents:** This is the Markdown version of a RevenueCat documentation page. For the complete documentation index, see [llms.txt](https://www.revenuecat.com/docs/llms.txt).

When you enable paywall analytics integrations, user interactions with paywall controls can generate **`paywall_component_interacted`** events (tabs, packages, purchase buttons, sheets, etc.). Field names and platform notes are summarized in [Paywall component interaction events](https://www.revenuecat.com/docs/integrations/third-party-integrations/supporting-files/paywall-component-interaction-events).

## Platform specific instructions

### iOS

RevenueCat Paywalls will show paywalls in a sheet or fullscreen on iPhone, and there are multiple ways to do this with SwiftUI and UIKit.

- Depending on an entitlement with `presentPaywallIfNeeded`
- Custom logic with `presentPaywallIfNeeded`
- Manually with `PaywallView` or `PaywallViewController`

**Entitlement**

```swift
import SwiftUI

import RevenueCat
import RevenueCatUI

struct App: View {
    var body: some View {
        ContentView()
            .presentPaywallIfNeeded(
                requiredEntitlementIdentifier: Constants.ENTITLEMENT_ID,
                purchaseCompleted: { customerInfo in
                    print("Purchase completed: \(customerInfo.entitlements)")
                },
                restoreCompleted: { customerInfo in
                    // Paywall will be dismissed automatically if the entitlement is now active.
                    print("Purchases restored: \(customerInfo.entitlements)")
                }
            )
    }
}
```

**Custom Logic**

```swift
import SwiftUI

import RevenueCat
import RevenueCatUI

struct App: View {
    var body: some View {
        ContentView()
            .presentPaywallIfNeeded { customerInfo in
                // Returning `true` will present the paywall
                return customerInfo.entitlements.active.keys.contains("pro")
            } purchaseCompleted: { customerInfo in
                print("Purchase completed: \(customerInfo.entitlements)")
            } restoreCompleted: { customerInfo in
                // Paywall will be dismissed automatically if "pro" is now active.
                print("Purchases restored: \(customerInfo.entitlements)")
            }
    }
}
```

**Manually**

```swift
import SwiftUI

import RevenueCat
import RevenueCatUI

struct App: View {
    @State
    var displayPaywall = false

    var body: some View {
        ContentView()
            .sheet(isPresented: self.$displayPaywall) {
                // We handle scroll views for you, no need to wrap this in a ScrollView
                PaywallView() 
            }
    }
}
```

**Manually (UIKit)**

```swift
import UIKit

import RevenueCat
import RevenueCatUI

class ViewController: UIViewController {

    @IBAction func presentPaywall() {
        let controller = PaywallViewController()
        controller.delegate = self

        present(controller, animated: true, completion: nil)
    }

}

extension ViewController: PaywallViewControllerDelegate {

    func paywallViewController(_ controller: PaywallViewController,
                               didFinishPurchasingWith customerInfo: CustomerInfo) {

    }

}
```

**Manually (UIKit and Objective-C)**

```objectivec

#import "ViewController.h"
@import RevenueCat;
@import RevenueCatUI;

@interface ViewController () <RCPaywallViewControllerDelegate>

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
}

- (IBAction)showPaywallTapped:(id)sender {
    [RCPurchases.sharedPurchases offeringsWithCompletionHandler:^(RCOfferings * _Nullable offerings, NSError * _Nullable error) {
        if (error) {
            NSLog(@"Error fetching offerings: %@", error.localizedDescription);
            return;
        }

        RCOffering *offering = offerings.current;
        if (offering) {
            dispatch_async(dispatch_get_main_queue(), ^{
                NSLog(@"Current offering identifier: %@", offering.identifier);
                RCPaywallViewController *controller = [[RCPaywallViewController alloc] initWithOffering:offering
                                                                                     displayCloseButton:YES
                                                                                 shouldBlockTouchEvents:NO
                                                                                dismissRequestedHandler:^(RCPaywallViewController * _Nonnull controller) {
                    NSLog(@"dismiss request!");
                    [controller dismissViewControllerAnimated:YES completion:nil];
                }];
                controller.delegate = self;
                [self presentViewController:controller animated:YES completion:nil];
            });
        } else {
            NSLog(@"No current offering available");
        }
    }];
}

#pragma mark - PaywallViewControllerDelegate

- (void)paywallViewController:(RCPaywallViewController *)controller
didFinishPurchasingWithCustomerInfo:(RCCustomerInfo *)customerInfo {
    // Handle purchase completion here
}

@end
```

#### Paywalls on iPad

When using `presentPaywallIfNeeded` to display a paywall on iPad, we'll automatically show a paywall in a modal that is roughly iPhone sized. If instead you prefer to show a paywall that is full screen on iPad, you can use the `PaywallView` or `PaywallViewController` methods instead.

![Paywalls on iPad](https://www.revenuecat.com/docs_images/paywalls/paywalls-on-ipad.jpeg)

### Android

RevenueCat Paywalls will, by default, show paywalls fullscreen and there are multiple ways to do this with `Activity`s and Jetpack Compose.

- Depending on an entitlement with `PaywallDialog`
- Custom logic with `PaywallDialog`
- Manually with `Paywall`, `PaywallDialog`, or `PaywallActivityLauncher`

**Entitlement**

```kotlin
@OptIn(ExperimentalPreviewRevenueCatUIPurchasesAPI::class)
@Composable
private fun LockedScreen() {
    YourContent()

    PaywallDialog(
        PaywallDialogOptions.Builder()
            .setRequiredEntitlementIdentifier(Constants.ENTITLEMENT_ID)
            .setListener(
                object : PaywallListener {
                    override fun onPurchaseCompleted(customerInfo: CustomerInfo, storeTransaction: StoreTransaction) {}
                    override fun onRestoreCompleted(customerInfo: CustomerInfo) {}
                }
            )
            .build()
    )
}
```

**Custom Logic**

```kotlin
@OptIn(ExperimentalPreviewRevenueCatUIPurchasesAPI::class)
@Composable
private fun NavGraph(navController: NavHostController) {
    NavHost(
        navController = navController,
        startDestination = Screen.Main.route,
    ) {
        composable(route = Screen.Main.route) {
            MainScreen()

            PaywallDialog(
                PaywallDialogOptions.Builder()
                    .setShouldDisplayBlock { !it.entitlements.active.isEmpty() }
                    .setListener(
                        object : PaywallListener {
                            override fun onPurchaseCompleted(customerInfo: CustomerInfo, storeTransaction: StoreTransaction) {}
                            override fun onRestoreCompleted(customerInfo: CustomerInfo) {}
                        }
                    )
                    .build()
            )
        }
    }
}
```

**Manually**

```kotlin
@OptIn(ExperimentalPreviewRevenueCatUIPurchasesAPI::class)
@Composable
private fun NavGraph(navController: NavHostController) {
    NavHost(
        navController = navController,
        startDestination = Screen.Main.route,
    ) {
        composable(route = Screen.Main.route) {
            MainScreen()
        }

        composable(route = Screen.Paywall.route) {
            Paywall(
                options = PaywallOptions.Builder(
                    onDismiss = { navController.popBackStack() }
                )
                    .setListener(
                        object : PaywallListener {
                            override fun onPurchaseCompleted(customerInfo: CustomerInfo, storeTransaction: StoreTransaction) {}
                            override fun onRestoreCompleted(customerInfo: CustomerInfo) {}
                        }
                    )
                    .build()
            )
        }
    }
}
```

**Manually (Activity)**

```kotlin
@OptIn(ExperimentalPreviewRevenueCatUIPurchasesAPI::class)
class MainActivity : AppCompatActivity(), PaywallResultHandler {
    private lateinit var paywallActivityLauncher: PaywallActivityLauncher
    private lateinit var root: View

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        paywallActivityLauncher = PaywallActivityLauncher(this, this)
    }

    private fun launchPaywallActivity() {
        paywallActivityLauncher.launchIfNeeded(requiredEntitlementIdentifier = Constants.ENTITLEMENT_ID)
    }

    override fun onActivityResult(result: PaywallResult) {}
}
```

**Manually (Activity) - Java**

```java
@OptIn(markerClass = ExperimentalPreviewRevenueCatUIPurchasesAPI.class)
public class MainActivity extends AppCompatActivity implements PaywallResultHandler {
    private PaywallActivityLauncher launcher;
    private static final String requiredEntitlementIdentifier = "MY_ENTITLEMENT_ID";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        launcher = new PaywallActivityLauncher(this, this);
    }

    private void launchPaywallActivity() {
        // This will launch the paywall only if the user doesn't have the given entitlement id active.
        launcher.launchIfNeeded(requiredEntitlementIdentifier);
        // or if you want to launch it without any conditions
        launcher.launch();
    }

    @Override
    public void onActivityResult(PaywallResult result) {
        // Handle result
    }
}
```

### React Native

There are several ways to present paywalls:

- Using `RevenueCatUI.presentPaywall`: this will display a paywall when invoked.
- Using `RevenueCatUI.presentPaywallIfNeeded`: this will present a paywall only if the customer does not have an unlocked entitlement.
- Manually presenting `<RevenueCatUI.Paywall>`: this gives you more flexibility on how the paywall is presented.

**RevenueCatUI.presentPaywall**

```jsx
import RevenueCatUI, { PAYWALL_RESULT } from "react-native-purchases-ui";

async function presentPaywall(): Promise<boolean> {
    // Present paywall for current offering:
    const paywallResult: PAYWALL_RESULT = await RevenueCatUI.presentPaywall();
    // or if you need to present a specific offering:
    const paywallResult: PAYWALL_RESULT = await RevenueCatUI.presentPaywall({
        offering: offering // Optional Offering object obtained through getOfferings
    });

    switch (paywallResult) {
        case PAYWALL_RESULT.NOT_PRESENTED:
        case PAYWALL_RESULT.ERROR:
        case PAYWALL_RESULT.CANCELLED:
            return false;
        case PAYWALL_RESULT.PURCHASED:
        case PAYWALL_RESULT.RESTORED:
            return true;
        default:
            return false;
    }
}

async function presentPaywallIfNeeded() {
    // Present paywall for current offering:
    const paywallResult: PAYWALL_RESULT = await RevenueCatUI.presentPaywallIfNeeded({
        requiredEntitlementIdentifier: "pro"
    });
    // If you need to present a specific offering:
    const paywallResult: PAYWALL_RESULT = await RevenueCatUI.presentPaywallIfNeeded({
        offering: offering, // Optional Offering object obtained through getOfferings
        requiredEntitlementIdentifier: "pro"
    });
}
```

**RevenueCatUI.Paywall**

```jsx
import React from 'react';
import { View } from 'react-native';

import RevenueCatUI from 'react-native-purchases-ui';

// Display current offering
return (
    <View style={{ flex: 1 }}>
        <RevenueCatUI.Paywall 
          onDismiss={() => {
            // Dismiss the paywall, i.e. remove the view, navigate to another screen, etc.
            // Will be called when the close button is pressed (if enabled) or when a purchase succeeds.
          }}
        />
    </View>
);

// If you need to display a specific offering:
return (
    <View style={{ flex: 1 }}>
        <RevenueCatUI.Paywall
          options={{
            offering: offering // Optional Offering object obtained through getOfferings
          }}
          onRestoreCompleted={({customerInfo}: { customerInfo: CustomerInfo }) => {
            // Optional listener. Called when a restore has been completed.
            // This may be called even if no entitlements have been granted.
          }
          onDismiss={() => {
            // Dismiss the paywall, i.e. remove the view, navigate to another screen, etc.
            // Will be called when the close button is pressed (if enabled) or when a purchase succeeds.
          }}
        />
    </View>
);
```

There are also several listeners that can be used to handle the paywall lifecycle, such as `onPurchaseStarted`, `onPurchaseCompleted`, and `onRestoreStarted`.

#### Listeners

When using `RevenueCatUI.Paywall`, you may use one of the provided listeners to react to user actions.

Available listeners at this time are:

- onPurchaseStarted
- onPurchaseCompleted
- onPurchaseError
- onPurchaseCancelled
- onRestoreStarted
- onRestoreCompleted
- onRestoreError
- onDismiss

### Flutter

There are several ways to present paywalls:

- Using `RevenueCatUI.presentPaywall`: this will display a paywall when invoked.
- Using `RevenueCatUI.presentPaywallIfNeeded`: this will present a paywall only if the customer does not have an unlocked entitlement.
- Manually presenting `PaywallView`: this gives you more flexibility on how the paywall is presented.

**RevenueCatUI.presentPaywall**

```dart
import 'dart:async';
import 'dart:developer';

import 'package:purchases_ui_flutter/purchases_ui_flutter.dart';

void presentPaywall() async {
  final paywallResult = await RevenueCatUI.presentPaywall();
  log('Paywall result: $paywallResult');
}

void presentPaywallIfNeeded() async {
  final paywallResult = await RevenueCatUI.presentPaywallIfNeeded("pro");
  log('Paywall result: $paywallResult');
}
```

**PaywallView**

```dart
import 'package:purchases_ui_flutter/purchases_ui_flutter.dart';

// Note: Avoid placing PaywallView inside a modal or bottom sheet (e.g., using showModalBottomSheet).
// Instead, include it directly in your widget.
@override
Widget build(BuildContext context) {
  return Scaffold(
    body: SafeArea(
      child: Center(
        child: PaywallView(
          offering: offering, // Optional Offering object obtained through getOfferings
          onRestoreCompleted: (CustomerInfo customerInfo) {
            // Optional listener. Called when a restore has been completed.
            // This may be called even if no entitlements have been granted.
          } 
          onDismiss: () {
            // Dismiss the paywall, i.e. remove the view, navigate to another screen, etc.
            // Will be called when the close button is pressed (if enabled) or when a purchase succeeds.
          },
        ),
      ),
    ),
  );
}
```

#### Listeners

When using `PaywallView`, you may use one of the provided listeners to react to user actions.
Available listeners at this time are:

- onPurchaseStarted
- onPurchaseCompleted
- onPurchaseError
- onRestoreCompleted
- onRestoreError
- onDismiss

### Kotlin Multiplatform

You can present a fullscreen Paywall using the `Paywall` composable. You have the flexibility to decide when to call this. You could, for instance, add it to your navigation graph.

```kotlin
val options = remember {
    PaywallOptions(dismissRequest = { TODO("Handle dismiss") })
}

Paywall(options)
```

#### Listeners

When using `Paywall`, you may use one of the provided listeners to react to user actions.
Available listeners at this time are:

- onPurchaseStarted
- onPurchaseCompleted
- onPurchaseError
- onPurchaseCancelled
- onRestoreStarted
- onRestoreCompleted
- onRestoreError

### Capacitor

There are several ways to present paywalls:

- Using `RevenueCatUI.presentPaywall`: this will display a paywall when invoked.
- Using `RevenueCatUI.presentPaywallIfNeeded`: this will present a paywall only if the customer does not have an unlocked entitlement.

```jsx
import { RevenueCatUI } from '@revenuecat/purchases-capacitor-ui';
import { PAYWALL_RESULT } from '@revenuecat/purchases-capacitor';

async function presentPaywall(): Promise<boolean> {
    // Present paywall for current offering:
    const { result } = await RevenueCatUI.presentPaywall();
    // or if you need to present a specific offering:
    const { result } = await RevenueCatUI.presentPaywall({
        offering: offering // Optional Offering object obtained through getOfferings
    });

    // Handle result if needed.
    switch (paywallResult) {
        case PAYWALL_RESULT.NOT_PRESENTED:
        case PAYWALL_RESULT.ERROR:
        case PAYWALL_RESULT.CANCELLED:
            return false;
        case PAYWALL_RESULT.PURCHASED:
        case PAYWALL_RESULT.RESTORED:
            return true;
        default:
            return false;
    }
}

async function presentPaywallIfNeeded() {
    // Present paywall for current offering:
    const { result } = await RevenueCatUI.presentPaywallIfNeeded({ requiredEntitlementIdentifier: 'YOUR_ENTITLEMENT_ID'});

    // If you need to present a specific offering:
    const { result } = await RevenueCatUI.presentPaywallIfNeeded({
        offering: offering, // Optional Offering object obtained through getOfferings
        requiredEntitlementIdentifier: "pro"
    });

    // Handle result if needed.
}
```

### Unity

RevenueCat Paywalls for Unity provide native paywall presentation on iOS and Android with simple C# APIs.

There are several ways to present paywalls:

- Using `PaywallsPresenter.Present()`: this will display a paywall when invoked.
- Using `PaywallsPresenter.PresentIfNeeded()`: this will present a paywall only if the customer does not have an unlocked entitlement.
- Using `PaywallsBehaviour` component: configure paywalls through Unity's Inspector for Editor workflows.

**PaywallsPresenter.Present**

```cpp
using System.Threading.Tasks;
using RevenueCatUI;

public class ShowPaywallExample : MonoBehaviour
{
    public async Task ShowPaywall()
    {
        var result = await PaywallsPresenter.Present();

        switch (result.Result)
        {
            case PaywallResultType.Purchased:
                // User made a purchase
                break;
            case PaywallResultType.Restored:
                // User restored purchases
                break;
            case PaywallResultType.NotPresented:
                // Paywall was not presented
                break;
            case PaywallResultType.Cancelled:
                // User cancelled the paywall
                break;
            case PaywallResultType.Error:
                // An error occurred
                break;
        }
    }
}
```

**PaywallsPresenter.PresentIfNeeded**

```cpp
using System.Threading.Tasks;
using RevenueCatUI;

public class ConditionalPaywallExample : MonoBehaviour
{
    public async Task ShowPaywallIfNeeded()
    {
        var result = await PaywallsPresenter.PresentIfNeeded(
            requiredEntitlementIdentifier: "pro"
        );

        switch (result.Result)
        {
            case PaywallResultType.Purchased:
                // User made a purchase
                break;
            case PaywallResultType.Restored:
                // User restored purchases
                break;
            case PaywallResultType.NotPresented:
                // User already has the entitlement
                break;
            case PaywallResultType.Cancelled:
                // User cancelled the paywall
                break;
            case PaywallResultType.Error:
                // An error occurred
                break;
        }
    }
}
```

**PaywallOptions**

```cpp
using System.Threading.Tasks;
using RevenueCatUI;

public class PaywallOptionsExample : MonoBehaviour
{
    // Present the current offering
    public async Task ShowCurrentOffering()
    {
        var result = await PaywallsPresenter.Present(new PaywallOptions());
        // Handle result...
    }

    // Present from an Offering object
    public void ShowOfferingFromObject()
    {
        var purchases = GetComponent<Purchases>();
        purchases.GetOfferings(async (offerings, error) =>
        {
            if (error != null)
            {
                // Handle error
                return;
            }

            var offering = offerings.Current;
            var result = await PaywallsPresenter.Present(new PaywallOptions(offering));
            // Handle result...
        });
    }

    // Display close button (for original template paywalls only)
    public async Task ShowPaywallWithCloseButton()
    {
        var result = await PaywallsPresenter.Present(new PaywallOptions(displayCloseButton: true));
        // Handle result...
    }

    // Present paywall in full screen on all platforms
    public async Task ShowFullScreenPaywall()
    {
        var options = new PaywallOptions(
            presentationConfiguration: PaywallPresentationConfiguration.FullScreen
        );
        var result = await PaywallsPresenter.Present(options);
        // Handle result...
    }
}
```

#### Full-Screen Presentation

By default, paywalls are presented as a modal sheet on iOS and full screen on Android. You can configure the presentation style using `PaywallPresentationConfiguration`:

- `PaywallPresentationConfiguration.FullScreen`: Full-screen presentation on all platforms.
- `PaywallPresentationConfiguration.Default`: Platform defaults (sheet on iOS, full screen on Android).
- Custom per-platform: Create a `new PaywallPresentationConfiguration(ios: IOSPaywallPresentationStyle.FullScreen)` to configure each platform independently.

See the **PaywallOptions** tab above for full-screen presentation examples.

#### Using PaywallsBehaviour Component

For developers who prefer configuring paywalls through Unity's Inspector, you can use the `PaywallsBehaviour` MonoBehaviour component:

1. Add a `PaywallsBehaviour` component to any GameObject
2. Configure the options in the Inspector:
   - **Offering Identifier**: Leave empty to use current offering, or specify an offering ID
   - **Display Close Button**: Whether to show a close button (original templates only)
   - **Required Entitlement Identifier**: Optional - paywall will only show if user lacks this entitlement
3. Wire up UnityEvent callbacks: `OnPurchased`, `OnRestored`, `OnCancelled`, `OnNotPresented`, `OnError`
4. Call `PresentPaywall()` method (e.g., from a UI Button's OnClick event)

#### Platform Notes

- The paywall UI is only available on iOS and Android device builds.
- Unity Editor is not supported for displaying the paywall UI.
- Build to device when testing the paywall UI.

### Web

Paywalls are supported on the web through RevenueCat Web purchase flows. To serve your Paywall to customers on the web via a new or existing Web Purchase Link, [click here](https://www.revenuecat.com/docs/web/paywalls).
You can also present the same remotely configured paywall directly within your own web experience using the [purchases-js SDK](https://www.revenuecat.com/docs/web/web-billing/web-sdk). Call `presentPaywall` with the element that should host the paywall. Optionally, provide an offering from `purchases.getOfferings()` to choose which paywall to display. The SDK renders the paywall, runs the purchase, and resolves with the result.

```ts title="Presenting a paywall from purchases-js"
async function showPaywall() {
  const paywallContainer = document.getElementById("paywall-container");
  try{
      const purchaseResult = await purchases.presentPaywall({
        htmlTarget: paywallContainer,
        // no offering specified – defaults to the current offering
      });
      console.log("Purchase completed", purchaseResult);
  } catch (e){
      console.log("Something went wrong while purchasing through Paywalls");
  }
}
```

If you omit the `offering` parameter (or pass `undefined`), the paywall from the offering marked as `current` will be displayed.

You can also specify an offering by passing it as parameter to `presentPaywall`.

```ts title="Presenting a paywall from purchases-js with a specific offering"
async function showPaywall() {
  const offerings=await purchases.getOfferings();
  const offeringToUse= offerings.all['offeringIdToUse'];
  const paywallContainer = document.getElementById("paywall-container");
  try{
      const purchaseResult = await purchases.presentPaywall({
        htmlTarget: paywallContainer,
        offering: offeringToUse
      });
      console.log("Purchase completed", purchaseResult);
  } catch (e){
      console.log("Something went wrong while purchasing through Paywalls");
  }
}
```

:::info\[Max width]
Paywalls on the web have a maximum width of 968px.
Individual components can be configured to have their own width settings within that max width of the paywall itself.

When used in Web Purchase Links, if the window the paywall is being displayed in is wider than 968px, the additional space will be filled based on the specified background color of your WPL. [Learn more](https://www.revenuecat.com/docs/web/web-billing/web-purchase-links)
:::

## Handling paywall navigation

When creating a paywall, consider whether it will be presented in a sheet, or as a full screen view. Sheets won't require a dedicated close button. Full screen views should have either a close button (if presented modally) or a back button (if part of a navigation stack or host) unless you intend to provide a hard paywall to your customers that cannot be bypassed.

## Setting preferred locale

You can set a preferred locale for your paywalls to display in a specific language. This is useful when you want to override the device's default language for paywall content.

**iOS**

```swift
// In configure
let builder = Configuration 
    .builder(withAPIKey: "<YOUR_API_KEY>")
    .with(preferredUILocaleOverride: "es-ES")

// Or during runtime
Purchases.shared.overridePreferredUILocale("de-DE")


VStack {
    HStack {
        Icon(check)
        ZStack {
            VStack(alignment: leading) {
                Yearly
                Some other text
            }
            VStack(alignment: trailing) {
                $Price/month
            }
        }
    }
}
```

**Android**

```kotlin
// In configure
val configuration = PurchasesConfiguration.Builder(context, "<YOUR_API_KEY>")
    .preferredUILocaleOverride("es-ES")
    .build()
Purchases.configure(configuration)

// Or during runtime
Purchases.sharedInstance.overridePreferredUILocale("de-DE")
```

**React Native**

```jsx
// In configure
Purchases.configure({
  apiKey: '<YOUR_API_KEY>',
  // other configuration options...
  preferredUILocaleOverride: 'es-ES'
});

// Or during runtime
await Purchases.overridePreferredLocale('de-DE');
```

**Flutter**

```dart
// In configure
await Purchases.configure(
  PurchasesConfiguration('<YOUR_API_KEY>')
    // other configuration options...
    ..preferredUILocaleOverride = 'es-ES',
);

// Or during runtime
await Purchases.overridePreferredUILocale('de-DE');
```

**Capacitor**

```jsx
// In configure
await Purchases.configure({
  apiKey: '<YOUR_API_KEY>',
  // other configuration options...
  preferredUILocaleOverride: 'es-ES',
});

// Or during runtime
await Purchases.overridePreferredUILocale({ locale: 'de-DE' });
```

## Custom variables

Custom variables allow you to pass dynamic values from your app to display in your paywall text. This is useful for displaying values like user-specific information, app state, or promotional messaging that changes based on your app's context.

Custom variables support **string**, **number**, and **boolean** value types. For details on how each type is displayed, see the [custom variables reference](https://www.revenuecat.com/docs/tools/paywalls/creating-paywalls/variables#custom-variables).

When you create custom variables in the [paywall editor](https://www.revenuecat.com/docs/tools/paywalls/creating-paywalls/variables#custom-variables), you set default values that will be used as fallbacks. When displaying the paywall in your app, you can override these defaults with actual runtime values specific to the user's session.

Custom variables are used for both [text replacement](https://www.revenuecat.com/docs/tools/paywalls/creating-paywalls/variables#custom-variables) and [visibility rules](https://www.revenuecat.com/docs/tools/paywalls/creating-paywalls/rules). The same values you pass here will be used to evaluate any visibility conditions based on custom variables.

### iOS

**SwiftUI**

```swift
import SwiftUI
import RevenueCat
import RevenueCatUI

struct App: View {
    @State
    var displayPaywall = false

    var body: some View {
        ContentView()
            .sheet(isPresented: self.$displayPaywall) {
                PaywallView()
                    .customPaywallVariables([
                        "player_name": .string("John"),
                        "level": .number(42),
                        "is_premium": .bool(true)
                    ])
            }
    }
}
```

**UIKit**

```swift
import UIKit
import RevenueCat
import RevenueCatUI

class MyViewController: UIViewController {
    func showPaywall() {
        let controller = PaywallViewController()

        // Set custom variables
        controller.setCustomVariable("John", forKey: "player_name")
        controller.setCustomVariableNumber(42, forKey: "level")
        controller.setCustomVariableBool(true, forKey: "is_premium")

        self.present(controller, animated: true)
    }
}
```

### Android

**Composable**

```kotlin
import androidx.compose.runtime.Composable
import com.revenuecat.purchases.ui.revenuecatui.PaywallDialog
import com.revenuecat.purchases.ui.revenuecatui.PaywallDialogOptions
import com.revenuecat.purchases.ui.revenuecatui.CustomVariableValue

@Composable
fun PaywallWithCustomVariables(
    onDismiss: () -> Unit
) {
    val options = PaywallDialogOptions.Builder()
        .setCustomVariables(
            mapOf(
                "player_name" to CustomVariableValue.String("John"),
                "level" to CustomVariableValue.Number(42),
                "is_premium" to CustomVariableValue.Boolean(true)
            )
        )
        .setShouldDisplayBlock { !it.entitlements.active.containsKey("pro") }
        .setDismissRequest(onDismiss)
        .build()

    PaywallDialog(paywallDialogOptions = options)
}
```

**Activity**

```kotlin
import com.revenuecat.purchases.CustomerInfo
import com.revenuecat.purchases.Offering
import com.revenuecat.purchases.ui.revenuecatui.activity.PaywallActivityLauncher
import com.revenuecat.purchases.ui.revenuecatui.CustomVariableValue

class MyActivity : ComponentActivity() {
    private val paywallActivityLauncher = PaywallActivityLauncher(this)

    fun showPaywall(offering: Offering) {
        paywallActivityLauncher.launch(
            offering = offering,
            customVariables = mapOf(
                "player_name" to CustomVariableValue.String("John"),
                "level" to CustomVariableValue.Number(42),
                "is_premium" to CustomVariableValue.Boolean(true)
            )
        )
    }
}
```

### React Native

**presentPaywall**

```jsx
import RevenueCatUI, { CustomVariableValue } from 'react-native-purchases-ui';

async function presentPaywallWithCustomVariables() {
  const result = await RevenueCatUI.presentPaywall({
    customVariables: {
      'player_name': CustomVariableValue.string('John'),
      'level': CustomVariableValue.number(42),
      'is_premium': CustomVariableValue.boolean(true),
    },
  });
  console.log('Paywall result:', result);
}
```

**Paywall**

```jsx
import React from 'react';
import { View } from 'react-native';
import RevenueCatUI, { CustomVariableValue } from 'react-native-purchases-ui';

function PaywallScreen() {
  return (
    <View style={{ flex: 1 }}>
      <RevenueCatUI.Paywall
        options={{
          customVariables: {
            'player_name': CustomVariableValue.string('John'),
            'level': CustomVariableValue.number(42),
            'is_premium': CustomVariableValue.boolean(true),
          },
        }}
        onDismiss={() => {
          console.log('Paywall dismissed');
        }}
      />
    </View>
  );
}
```

### Flutter

**presentPaywall**

```dart
import 'dart:developer';

import 'package:purchases_ui_flutter/purchases_ui_flutter.dart';

void presentPaywallWithCustomVariables() async {
  final paywallResult = await RevenueCatUI.presentPaywall(
    customVariables: {
      'player_name': CustomVariableValue.string('John'),
      'level': CustomVariableValue.number(42),
      'is_premium': CustomVariableValue.boolean(true),
    },
  );
  log('Paywall result: $paywallResult');
}
```

**PaywallView**

```dart
import 'package:flutter/material.dart';
import 'package:purchases_ui_flutter/purchases_ui_flutter.dart';

class PaywallScreen extends StatelessWidget {
  const PaywallScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Center(
          child: PaywallView(
            customVariables: {
              'player_name': CustomVariableValue.string('John'),
              'level': CustomVariableValue.number(42),
              'is_premium': CustomVariableValue.boolean(true),
            },
            onDismiss: () {
              Navigator.of(context).pop();
            },
          ),
        ),
      ),
    );
  }
}
```

:::info\[Availability]
Custom variables for Flutter paywalls require `purchases_ui_flutter` version 9.12.0 or later.
:::

### Kotlin Multiplatform

```kotlin
import com.revenuecat.purchases.kmp.ui.revenuecatui.CustomVariableValue
import com.revenuecat.purchases.kmp.ui.revenuecatui.Paywall
import com.revenuecat.purchases.kmp.ui.revenuecatui.PaywallOptions

val options = remember {
    PaywallOptions(dismissRequest = { TODO("Handle dismiss") }) {
        customVariables = mapOf(
            "player_name" to CustomVariableValue.string("John"),
            "level" to CustomVariableValue.number(42),
            "is_premium" to CustomVariableValue.boolean(true),
        )
    }
}

Paywall(options)
```

### Capacitor

```jsx
import { RevenueCatUI } from '@revenuecat/purchases-capacitor-ui';

async function presentPaywallWithCustomVariables() {
  const result = await RevenueCatUI.presentPaywall({
    customVariables: {
      'player_name': 'John',
      'level': 42,
      'is_premium': true,
    },
  });
  console.log('Paywall result:', result);
}
```

### Unity

**PaywallsPresenter.Present**

```cpp
using System.Collections.Generic;
using System.Threading.Tasks;
using RevenueCatUI;

public class ShowPaywallWithCustomVariables : MonoBehaviour
{
    public async Task ShowPaywall()
    {
        var options = new PaywallOptions(
            customVariables: new Dictionary<string, CustomVariableValue>
            {
                { "player_name", CustomVariableValue.String("John") },
                { "level", CustomVariableValue.Number(42) },
                { "is_premium", CustomVariableValue.Boolean(true) }
            }
        );

        var result = await PaywallsPresenter.Present(options);
    }
}
```

**PaywallsPresenter.PresentIfNeeded**

```cpp
using System.Collections.Generic;
using System.Threading.Tasks;
using RevenueCatUI;

public class ShowPaywallIfNeededWithCustomVariables : MonoBehaviour
{
    public async Task ShowPaywallIfNeeded()
    {
        var options = new PaywallOptions(
            customVariables: new Dictionary<string, CustomVariableValue>
            {
                { "player_name", CustomVariableValue.String("John") },
                { "level", CustomVariableValue.Number(42) },
                { "is_premium", CustomVariableValue.Boolean(true) }
            }
        );

        var result = await PaywallsPresenter.PresentIfNeeded("pro", options);
    }
}
```

:::info\[Variable naming]
Custom variable keys must start with a letter and can only contain letters, numbers, and underscores. In your paywall text, reference them using the template syntax: `{{ custom.variable_name }}`
:::

## Custom fonts

Using custom fonts in your paywall can now be done by uploading font files directly to RevenueCat. See the [Custom fonts](https://www.revenuecat.com/docs/tools/paywalls/creating-paywalls/components#custom-fonts) section for more information.

### Including custom fonts in your app

To improve the performance and reduce loading times of your paywall using custom fonts, you can add the font to your app's resources using the instructions below.

#### Android

To add a custom font to your Android app, place the font file in the `res/font` folder. Make sure that the filename (without the extension) corresponds to the font name in the paywall editor. See [the official Android documentation](https://developer.android.com/develop/ui/views/text-and-emoji/fonts-in-xml) for more information.

#### iOS

To add a custom font to your iOS app, go to *File* and then *Add Files to "Your Project Name"*. The font file should be a target member of your app, and be registered with iOS by adding the "Fonts provided by the application" key to your *Info.plist* file. Make sure that the filename (without the extension) corresponds to the font name in the paywall editor. See [the official iOS documentation](https://developer.apple.com/documentation/uikit/adding-a-custom-font-to-your-app) for more information.

#### Kotlin Multiplatform, React Native, and Flutter

Adding custom fonts to a hybrid app involves adding the font files to the underlying Android and iOS projects following the instructions above.

## Intercepting an initiated purchase

You can intercept a purchase flow before it begins by using the `onPurchaseInitiated` view modifier in iOS and the `onPurchasePackageInitiated` method on the `PaywallListener` interface in Android. This allows you to perform custom logic, such as showing additional information to the user or performing validation checks, before the purchase proceeds.

When a user initiates a purchase, your interceptor callback will be invoked with:

- The `Package` that the user selected
- A `ResumeAction` closure that you must call to either proceed with or cancel the purchase

The purchase flow will pause until you call the `resume` action. If you call `resume(false)`, the purchase will be cancelled. If you call `resume(true)`, the purchase will proceed as normal.

:::info\[Availability]
This feature is currently available on the native iOS and Android SDKs.
:::

**iOS**

```swift
PaywallView()
    .onPurchaseInitiated({ package, resume in
        print("User is attempting to purchase \(package)")
        Task {
            do {
                try await performAuthentication()
                print("Authentication Complete, presenting purchase flow")
                resume()
            } catch {
                print("Authentication failed… cancel purchase flow")
                resume(shouldProceed: false)
                presentErrorAlertForAuthentication()
            }
        }
    })
    // in iOS we can also intercept offer code redemption flows
    .onOfferCodeRedemptionInitiated({ resume in
        print("Offer code redemption initiated")
        Task {
            do {
                try await performAuthentication()
                print("Authentication Complete, presenting redeem flow")
                resume()
            } catch {
                print("Authentication failed… cancel redeem flow")
                resume(shouldProceed: false)
                presentErrorAlertForAuthentication()
            }
        }
    })
```

**Android**

```kotlin
@OptIn(ExperimentalPreviewRevenueCatUIPurchasesAPI::class)
@Composable
private fun LockedScreen() {
    YourContent()

    PaywallDialog(
        PaywallDialogOptions.Builder()
            .setRequiredEntitlementIdentifier(Constants.ENTITLEMENT_ID)
            .setShouldDisplayDismissButton(false)
            .setListener(
                object : PaywallListener {
                    override fun onPurchasePackageInitiated(rcPackage: Package, resume: Resumable) { 
                        println("on purchase initiated for $rcPackage")
                        performAuthentication { result -> 
                            when (result) {
                                is Success -> resume().also { 
                                    println("Authentication complete, presenting purchase flow")
                                }
                                is Failure -> resume(false).also {
                                    println("Authentication failed, canceling purchase flow")
                                    presentErrorAlert()
                                }
                            }
                        }
                    }
                }
            )
            .build()
    )
}
```

## Intercepting an initiated restore

You can intercept a restore flow before it begins by using the `onRestoreInitiated` view modifier in iOS and the `onRestoreInitiated` method on the `PaywallListener` interface in Android. If you are presenting a paywall with UIKit, you can also implement `paywallViewController(_:didInitiateRestoreWith:)` on `PaywallViewControllerDelegate`.

When a user initiates a restore, your interceptor callback will be invoked with a resume callback that you must call to either proceed with or cancel the restore.

The restore flow will pause until you call the `resume` action. If you call `resume(false)`, the restore will be cancelled. If you call `resume(true)` or `resume()`, the restore will proceed as normal.

:::info\[Availability]
This feature is currently available on the native iOS and Android SDKs starting from version 5.67.0 on iOS and 9.28.0 on Android.
:::

**iOS (SwiftUI)**

```swift
PaywallView()
    .onRestoreInitiated { resume in
        print("User is attempting to restore purchases")
        Task {
            do {
                try await performAuthentication()
                print("Authentication complete, presenting restore flow")
                resume()
            } catch {
                print("Authentication failed, cancel restore flow")
                resume(shouldProceed: false)
                presentErrorAlertForAuthentication()
            }
        }
    }
```

**iOS (UIKit)**

```swift
extension ViewController: PaywallViewControllerDelegate {

    func paywallViewController(_ controller: PaywallViewController,
                               didInitiateRestoreWith resume: @escaping (Bool) -> Void) {
        Task {
            do {
                try await performAuthentication()
                print("Authentication complete, presenting restore flow")
                resume(true)
            } catch {
                print("Authentication failed, cancel restore flow")
                resume(false)
                presentErrorAlertForAuthentication()
            }
        }
    }

}
```

**Android**

```kotlin
@OptIn(ExperimentalPreviewRevenueCatUIPurchasesAPI::class)
@Composable
private fun LockedScreen() {
    YourContent()

    PaywallDialog(
        PaywallDialogOptions.Builder()
            .setRequiredEntitlementIdentifier(Constants.ENTITLEMENT_ID)
            .setShouldDisplayDismissButton(false)
            .setListener(
                object: PaywallListener {
                    override fun onRestoreInitiated(resume: Resumable) {
                        println("User is attempting to restore purchases")
                        performAuthentication { result ->
                            when (result) {
                                is Success -> resume().also {
                                    println("Authentication complete, presenting restore flow")
                                }
                                is Failure -> resume(false).also {
                                    println("Authentication failed, canceling restore flow")
                                    presentErrorAlert()
                                }
                            }
                        }
                    }
                }
            )
            .build()
    )
}
```

## Exit offers

Exit offers allow you to present an alternative offer when a user dismisses your paywall without making a purchase. This is a powerful tool for recovering potentially lost conversions by giving users a second chance to subscribe with a different offer, such as a discounted price or alternative package.

When a user tries to close or navigate back from the paywall, instead of immediately dismissing, a secondary paywall will be displayed with the exit offer.

### How it works

1. User attempts to dismiss the main paywall without purchasing
2. An exit offer paywall is automatically presented with an alternative offering
3. The user can either:
   - Subscribe to the exit offer
   - Dismiss the exit offer, which then dismisses the main paywall

### Configuration

Exit offers are configured entirely in the [Paywalls editor](https://www.revenuecat.com/docs/tools/paywalls/creating-paywalls#configuring-exit-offers) in the RevenueCat dashboard. No code changes are required to use this feature. Once configured, the exit offer will automatically be presented when users attempt to dismiss the paywall, as long as you're using a supported presentation method.

### Supported presentation methods

Exit offers only work when using the `presentPaywall` or `presentPaywallIfNeeded` functions that display paywalls in iOS and cross platform SDKs. In Android, they work automatically when using the `PaywallDialog` and `PaywallActivity` classes.

:::warning\[View-based APIs not supported]
Exit offers will **not** work if you manually embed paywall views/components in your UI (e.g., `PaywallView`, `Paywall` composable, `<RevenueCatUI.Paywall>` component, etc).
:::

### Platform support

| Platform      | Minimum Version |
| :------------ | :-------------- |
| iOS           | 5.52.0+         |
| Android       | 9.18.0+         |
| React Native  | 9.6.15+         |
| Flutter       | 9.10.5+         |
| Capacitor     | 12.0.3+         |
| Unity         | 8.4.15+         |
| KMP           | Not supported   |

## Changes from legacy Paywalls

#### Footer Paywalls

Our current Paywalls no longer support footer Paywalls. If your app requests the Paywall for an Offering to display that has a current Paywall, it will display a default version of that paywall instead (see below). Footer mode can still be used on legacy Paywalls templates using the existing method, or the new `.originalTemplatePaywallFooter()` method on SDK versions that support our current Paywalls.

#### Close buttons

Our current Paywalls do not require the `displayCloseButton` parameter (or equivalent for other platforms), and it will have no effect if used, since close buttons can be optionally added directly to your paywall as a component if desired.

#### Font provider

Our current Paywalls do not support passing in a custom font provider as legacy Paywalls did. Instead, you can now configure Paywalls to use the fonts you've already installed in your app directly from the Dashboard. Using the original handler will have no effect on current Paywalls. For more information, [click here](https://www.revenuecat.com/docs/tools/paywalls/creating-paywalls/components#custom-fonts)

## Default Paywall

If you attempt to display a Paywall for an Offering that doesn't have one configured, or that has a Paywall configured which is not supported on the installed SDK version, the RevenueCatUI SDK will display a default Paywall.

The default paywall displays all packages in the Offering.

On iOS it uses the app's `accentColor` for styling.
On Android, it uses the app's `Material3`'s `ColorScheme`.

:::tip\[Targeting]
If your app supports our legacy Paywall templates, consider using Targeting to create an audience that only receives your new Paywall if they're using an SDK version that does not support our current Paywalls. This will ensure that older app versions continue to receive the Offering and Paywall that they support, while any app versions running a supported RC SDK version receive your new Paywall. [Learn more about Targeting.](https://www.revenuecat.com/docs/tools/targeting)
:::
