---
title: "Custom Purchase Flows and Paywalls with RevenueCat on Android"
description: "In this article, you'll explore building a custom paywall against the Purchases SDK."
language: "en"
publishedAt: "2026-09-18T05:45:39.514Z"
updatedAt: "2026-09-18T05:45:39.514Z"
authors:
  - name: "Jaewoong Eum"
    url: "https://www.revenuecat.com/blog/author/jaewoong-eum"
category: "Engineering"
categoryUrl: "https://www.revenuecat.com/blog/engineering"
canonical: "https://www.revenuecat.com/blog/engineering/custom-flows-android"
---

# Custom Purchase Flows and Paywalls with RevenueCat on Android

In this article, you'll explore building a custom paywall against the Purchases SDK.

## Table of contents

- [Two decisions, not one](#two-decisions-not-one)
- [Reading an offering into your own UI](#reading-an-offering-into-your-own-ui)
  - [Why there is no eligibility check on Android](#why-there-is-no-eligibility-check-on-android)
- [Making the purchase with PurchaseParams.Builder](#making-the-purchase-with-purchaseparamsbuilder)
  - [The callback interface](#the-callback-interface)
  - [Coroutines](#coroutines)
- [Reading the result](#reading-the-result)
  - [Entitlements](#entitlements)
  - [Restore](#restore)
- [Switching plans: Product changes and replacement modes](#switching-plans-product-changes-and-replacement-modes)
  - [Getting oldProductId right](#getting-oldproductid-right)
  - [The five replacement modes](#the-five-replacement-modes)
  - [Choosing a mode](#choosing-a-mode)
  - [DEFERRED reports back differently](#deferred-reports-back-differently)
- [Other PurchaseParams options](#other-purchaseparams-options)
  - [isPersonalizedPrice](#ispersonalizedprice)
  - [Add-ons](#add-ons)
- [Keeping RevenueCat's paywall UI](#keeping-revenuecats-paywall-ui)
  - [PaywallListener: Observing the flow](#paywalllistener-observing-the-flow)
  - [Gating the flow before it starts](#gating-the-flow-before-it-starts)
  - [Getting a result from a full screen paywall](#getting-a-result-from-a-full-screen-paywall)
  - [Custom variables](#custom-variables)
- [Owning the transaction with PaywallPurchaseLogic](#owning-the-transaction-with-paywallpurchaselogic)
- [Choosing an approach](#choosing-an-approach)
- [Conclusion](#conclusion)

RevenueCat's prebuilt `Paywall` composable handles the common case in one line, and for most apps that is where the story ends. But two questions come up over and over: how to run your own code when a purchase succeeds or fails, and how to control what Google Play does when a subscriber switches plans. Both answers live in builders, and both are easy to get wrong because the defaults are silent. A subscription upgrade that charges the wrong amount looks exactly like one that charges the right amount until a user complains.

In this article, you'll explore building a custom paywall against the Android `Purchases` SDK, every option on `PurchaseParams.Builder`, the five replacement modes that govern plan changes, purchase results in callback and coroutine form, and the customization surfaces on RevenueCat's own paywall UI: `PaywallOptions`, `PaywallListener`, `PaywallActivityLauncher`, and `PaywallPurchaseLogic`.

## **Two decisions, not one**

Before any code, separate two things that get conflated. Who owns the UI and who completes the transaction are independent choices, and the SDK lets you mix them.

- **Your own UI, RevenueCat completing the purchase**: `PurchaseParams` and `Purchases.purchase`.
- **RevenueCat's paywall UI, RevenueCat completing the purchase**: `PaywallOptions` and `PaywallListener`.
- **RevenueCat's paywall UI, your own code completing the purchase**: `PaywallOptions` and `PaywallPurchaseLogic`.
- **Your own UI, your own code completing the purchase**: your own `BillingClient` calls, followed by `syncPurchases`.
Most people asking how to customize the purchase flow want one of the first two. They both let RevenueCat complete the transaction and differ only on who draws the screen. This article covers those, plus the third, where your own code completes the Google Play transaction. The fourth is a migration topic rather than a customization one and is out of scope here.

You choose who completes the transaction at configuration time:

```
val configuration = PurchasesConfiguration.Builder(context, apiKey)
    .purchasesAreCompletedBy(PurchasesAreCompletedBy.REVENUECAT)
    .build()

Purchases.configure(configuration)
```

`PurchasesAreCompletedBy.REVENUECAT` is the default and means the SDK acknowledges verified purchases for you. `PurchasesAreCompletedBy.MY_APP` means it does not, and that obligation has a deadline covered in the last section. The older `observerMode(true)` setter still exists but is deprecated in favor of this one.

## **Reading an offering into your own UI**

A custom paywall starts by fetching offerings and rendering whatever the dashboard returned. The callback form takes a lambda pair:

```
Purchases.sharedInstance.getOfferingsWith(
    onError = { error -> _uiState.value = PaywallUiState.Error(error.message) },
    onSuccess = { offerings ->
        val current = offerings.current
        if (current == null) {
            _uiState.value = PaywallUiState.Error("No current offering")
        } else {
            _uiState.value = PaywallUiState.Success(current)
        }
    },
)
```

`offerings.current` is nullable, and an app that assumes otherwise crashes the first time someone forgets to mark an offering as current in the dashboard. From there, `offering.availablePackages` is the list you render. Each `Package` carries an `identifier`, a `packageType`, and the `product` itself.

There is one nullable accessor per standard package type, `offering.monthly`, `offering.annual`, `offering.weekly`, `offering.lifetime`, and so on for each `PackageType`. Lookup by identifier behaves differently and catches people out:

```
val monthly: Package? = offering.monthly
val custom: Package = offering.getPackage("my_custom_package")
```

`getPackage` returns a non-null `Package` and throws `NoSuchElementException` when the identifier is absent, as does the `offering["my_custom_package"]` operator form. Wrap it or use the nullable accessors.

For the price label, read through to the product:

```
val Package.buttonText: String
    get() = with(product) {
        if (type == ProductType.SUBS) {
            "${price.formatted} for ${period?.value} ${period?.unit?.name?.lowercase()}"
        } else {
            "${price.formatted} one time"
        }
    }
```

`price.formatted` is the localized string Google Play returned, already carrying the right currency symbol and grouping for the user's storefront. `price.amountMicros` and `price.currencyCode` are there when you need to compute something. `period` is returned only for Google subscriptions, so it is null for one time products and for Amazon.

### **Why there is no eligibility check on Android**

On iOS there is an eligibility check you call before showing a trial badge. On Android there is not, and looking for one is a common dead end. `getEligibleWinBackOffers` and `checkTrialOrIntroDiscountEligibility` do not exist in the Android SDK. Google Play bakes eligibility into what it returns: an offer the user is not eligible for is not in `subscriptionOptions` at all.

So you read the offers you were given. Two container types are involved and the naming is close enough to confuse. `SubscriptionOptions` is the collection of offers on a product, and each `SubscriptionOption` is one offer made of pricing phases. Accessors on the collection pick an offer, accessors on an option pick a phase:

```
val options = packageToPurchase.product.subscriptionOptions
val trialOption = options?.freeTrial
val introOption = options?.introOffer
val defaultOption = packageToPurchase.product.defaultOption
```

`SubscriptionOptions` implements `List<SubscriptionOption>`, so you can iterate it directly. On an individual option, the phase accessors are `freePhase`, `introPhase`, and `fullPricePhase`. There is no `freeTrialPeriod` on a Google product. That property exists only on `AmazonStoreProduct`. A single option can carry both a free phase and an intro phase.

To show what a multi-phase offer costs over time, join the phases:

```
val pricingText = option.pricingPhases.joinToString(separator = " then ") { phase ->
    "${phase.price.formatted} for ${phase.billingPeriod.iso8601}"
}
```

You might reach for `phase.offerPaymentMode` to label each phase instead of formatting the price. It is worth knowing its shape before you do. The type is `OfferPaymentMode?`, one of `FREE_TRIAL`, `SINGLE_PAYMENT`, or `DISCOUNTED_RECURRING_PAYMENT`, and it is null for any phase that is not `FINITE_RECURRING`. That includes the full price phase of every recurring subscription and every phase of a prepaid plan. So branch on it with a `when` that treats null as the ongoing price rather than as an error.

## **Making the purchase with **`**PurchaseParams.Builder**`

Every purchase goes through `PurchaseParams`. Its builder has exactly three public constructors, and which one you pick decides how much control you have over the offer:

1. `PurchaseParams.Builder(activity, packageToPurchase)` takes a `Package`.
1. `PurchaseParams.Builder(activity, storeProduct)` takes a `StoreProduct`.
1. `PurchaseParams.Builder(activity, subscriptionOption)` takes a specific `SubscriptionOption`.
The first two do not purchase the base plan. They purchase the product's `defaultOption`, and the builder KDoc spells out how that is chosen: offers tagged `rc-ignore-offer` or `rc-customer-center` are filtered out, then the option with the longest free trial or the cheapest first phase wins, then it falls back to the base plan. That is usually what you want, and it is why most apps never touch `SubscriptionOption` at all.

Pass a `SubscriptionOption` when you need to override that choice, for example when your paywall shows two offers side by side and the user picked the one that is not the default:

```
val params = PurchaseParams.Builder(activity, selectedOption).build()
```

The simplest possible purchase is the package form with nothing else set:

```
Purchases.sharedInstance.purchaseWith(
    PurchaseParams.Builder(activity, packageToPurchase).build(),
    onError = { error, userCancelled ->
        if (!userCancelled) showError(error)
    },
    onSuccess = { _, customerInfo ->
        unlockContent(customerInfo)
    },
)
```

Two things about that snippet need stating outright. First, the `onSuccess` transaction parameter is typed `StoreTransaction?`, nullable, unlike the non-null `StoreTransaction` you get from the `PurchaseCallback` interface. Writing `onSuccess = { transaction, _ -> log(transaction.orderId) }` does not compile. Discard it with `_` or use `transaction?.orderId`.

Second, `userCancelled` is not extra information. It is derived, in `PurchasesOrchestrator`, from a single comparison:

```
onError(
    error,
    error.code == PurchasesErrorCode.PurchaseCancelledError,
)
```

So checking the boolean and checking the code are equivalent. Either way, a user tapping outside the Google Play sheet is not an error you should surface. Showing a toast that says "Purchase failed" because someone changed their mind is a common defect in hand rolled paywalls.

### **The callback interface**

`purchaseWith` is a Kotlin extension. The interface form takes a `PurchaseCallback`, which declares `onCompleted` and inherits `onError(error, userCancelled)` from `PurchaseErrorCallback`, so you implement two methods:

```
Purchases.sharedInstance.purchase(
    purchaseParams = params,
    callback = object : PurchaseCallback {
        override fun onCompleted(storeTransaction: StoreTransaction, customerInfo: CustomerInfo) {
            unlockContent(customerInfo)
        }

        override fun onError(error: PurchasesError, userCancelled: Boolean) {
            if (!userCancelled) showError(error)
        }
    },
)
```

Here `onCompleted` hands you a non-null `StoreTransaction`, which is the reason to prefer this form when you need the purchase token. Note that `purchaseToken` is non-null but `orderId` is still `String?` even here, because it is absent for pending and test purchases.

### **Coroutines**

The suspend variants come in two flavors per operation, one that throws and one that returns `Result`:

```
viewModelScope.launch {
    try {
        val result = Purchases.sharedInstance.awaitPurchase(params)
        unlockContent(result.customerInfo)
    } catch (e: PurchasesTransactionException) {
        if (!e.userCancelled) {
            showError(e.error)
        }
    }
}
```

`awaitPurchase` returns a `PurchaseResult` holding `storeTransaction` and `customerInfo`. That type gets its `equals`, `hashCode`, and `toString` from the Poko compiler plugin rather than from `data class`, which means there is no generated `copy()` and no destructuring. Read the two properties directly.

`PurchasesTransactionException` is where `userCancelled` lives on the coroutine path, and it exposes `error`, `code`, and `underlyingErrorMessage`. That last one matters for support, because `PurchasesError.message` is not a server message. It is a computed property returning `code.description`, the same canned English sentence for every occurrence of a given code. `underlyingErrorMessage` carries what actually went wrong, so log both.

## **Reading the result**

### **Entitlements**

The `CustomerInfo` handed to your success callback is already up to date, so gate on it directly rather than issuing another fetch. Watch which collection you read from. `entitlements.all` contains every entitlement the user has ever had, including expired ones, while `entitlements.active` contains only the live ones. Reading `all` and forgetting to check `isActive` grants access to a lapsed subscriber.

```
val isPro = customerInfo.entitlements["pro"]?.isActive == true
val hasAnything = customerInfo.entitlements.active.isNotEmpty()
```

The indexing operator reads through `all`, which is why the `isActive` check is not optional there. `entitlements.active` is a `Map<String, EntitlementInfo>`, not a list, so `active.keys` gives you the identifiers.

Individual `EntitlementInfo` properties worth surfacing in a settings screen:

- `**willRenew**`: false once the user has turned off auto renew. Always true for lifetime access.
- `**expirationDate**`: null for lifetime access. For a trial, this is the trial expiration.
- `**periodType**`: one of `NORMAL`, `INTRO`, `TRIAL`, `PREPAID`.
- `**unsubscribeDetectedAt**`: when the user cancelled, if they did.
- `**billingIssueDetectedAt**`: when a payment problem started, if there is one.
The KDoc on the last two carries the important part. An entitlement can still be active while both are set, so branch on `isActive` for access control and treat the dates as context for messaging.

To react to changes that happen outside your purchase call, set the listener once and clear it:

```
Purchases.sharedInstance.updatedCustomerInfoListener = UpdatedCustomerInfoListener { info ->
    _customerInfo.value = info
}
```

This fires when the SDK updates its cache after an app launch, a purchase, a restore, or a fetch. It is not a server push, so it is not a substitute for calling `getCustomerInfo` when your UI needs current state.

### **Restore**

Restore is a single call with the same two shapes, and it is not optional. Both stores require a way for a user on a new device to recover access:

```
Purchases.sharedInstance.restorePurchasesWith(
    onError = { error -> showError(error) },
    onSuccess = { customerInfo -> refreshUi(customerInfo) },
)
```

One naming inconsistency to watch for when you implement the interfaces instead: `ReceiveCustomerInfoCallback` uses `onReceived`, while `SyncPurchasesCallback` uses `onSuccess`.

## **Switching plans: Product changes and replacement modes**

Everything above buys a new subscription. Moving an existing subscriber from one plan to another is a different operation, and it needs two more builder calls:

```
val params = PurchaseParams.Builder(activity, annualPackage)
    .oldProductId(currentSubscriptionId)
    .replacementMode(StoreReplacementMode.CHARGE_PRORATED_PRICE)
    .build()
```

Product changes work in the Play Store and the Galaxy Store. The Amazon Appstore ignores them.

### **Getting **`**oldProductId**`** right**

The value is the subscription id, not the id plus base plan. This trips people up because the place you naturally read it from returns the combined form:

```
val customerInfo = Purchases.sharedInstance.awaitCustomerInfo()
val oldProductId = customerInfo.activeSubscriptions
    .map { it.split(":").first() }
    .firstOrNull()
```

`activeSubscriptions` returns `subscriptionId:basePlanId` for Google subscriptions. The builder is forgiving here, its KDoc says anything after a `:` is ignored, but splitting yourself makes the intent visible.

### **The five replacement modes**

`StoreReplacementMode` replaced `GoogleReplacementMode` in SDK 10.3.0. The old enum and the old `.googleReplacementMode()` setter both still exist and are both deprecated, so new code should use `StoreReplacementMode` and `.replacementMode()`.

- `**WITHOUT_PRORATION**`: takes effect immediately. The user pays nothing today, then the full new price on the old plan's expiration date.
- `**WITH_TIME_PRORATION**`: takes effect immediately. The user pays nothing today, and the time remaining on the old plan becomes free time on the new one.
- `**CHARGE_PRORATED_PRICE**`: takes effect immediately. The user pays the price difference today and the billing date does not move.
- `**CHARGE_FULL_PRICE**`: takes effect immediately. The user pays the full new price today and the remaining time from the old plan is credited forward.
- `**DEFERRED**`: takes effect when the old plan expires. The user pays nothing today, then the new price on the old expiration date.
One structural note if you plan to branch on the mode you were given. `StoreReplacementMode` is not an enum, it is a class exposing five constants, so a `when` over it will not compile as exhaustive. Include an `else` branch.

The default is `WITHOUT_PRORATION`. It is set in the builder's internal state, not left null, so a product change with no `replacementMode` call is a real decision that was made for you. For a paid upgrade that is rarely the one you want, because the user gets the better plan immediately and pays nothing until their old period ends.

If you want to have a better undertsanding on the proration mode, check out [Understanding Google Play subscription proration: a developer’s guide](https://www.revenuecat.com/blog/engineering/google-proration).

### **Choosing a mode**

- **Upgrade, charge now**: `CHARGE_PRORATED_PRICE`. The user pays the difference and the renewal date does not move. This mode is only valid when the new plan's price per time unit is higher than the old one's, which Google Play checks and rejects otherwise, so it is an upgrade only mode rather than a general purpose one.
- **Upgrade, generous**: `WITH_TIME_PRORATION`. The user pays nothing today and their unused time becomes free days on the higher tier.
- **Downgrade**: `DEFERRED`. The user keeps what they paid for until it runs out, then drops to the cheaper plan. Charging someone immediately to receive less is a support ticket.
- **Crossgrade at the same price**: `WITH_TIME_PRORATION` or `DEFERRED`, depending on whether the switch should be felt now.
Two constraints come straight from the KDoc and are worth encoding as guards. `WITH_TIME_PRORATION` fails on the Play Store when both options belong to one `StoreProduct`, so use a different mode for base plan changes within a single product. `CHARGE_FULL_PRICE` is not supported by the Galaxy Store, and passing it there surfaces a `PurchasesErrorCode.UnsupportedError` in your error callback rather than starting a purchase.

### `**DEFERRED**`** reports back differently**

The deferred mode is the one that makes purchase handling code look broken. Nothing changes today, so the `CustomerInfo` your success callback receives still describes the old plan. Code that reads the new product id out of the callback and writes it somewhere will write the old one.

Under `DEFERRED` the product id is also what matches the purchase callback to the transaction Google Play returns, which is another reason to pass the plain subscription id. Treat a deferred change as scheduled rather than done, and let the entitlement's `expirationDate` and your webhook pipeline tell you when it happened.

## **Other **`**PurchaseParams**`** options**

### `**isPersonalizedPrice**`

```
val params = PurchaseParams.Builder(activity, packageToPurchase)
    .isPersonalizedPrice(true)
    .build()
```

This is a compliance flag, not a pricing feature. It tells Google Play to disclose in the purchase sheet that the price shown was personalized for this user, which EU consumer law requires when that is true. The default is false, and the Amazon Appstore ignores it. Set it only when you are actually personalizing the price.

### **Add-ons**

For Play Store subscriptions with add-ons, the builder takes extra items:

```
@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class)
val params = PurchaseParams.Builder(activity, basePackage)
    .addOnPackages(listOf(addOnPackage))
    .build()
```

There are matching `addOnStoreProducts` and `addOnSubscriptionOptions` overloads. All three sit behind `@ExperimentalPreviewRevenueCatPurchasesAPI`, and the restrictions from the KDoc are strict: Play Store subscriptions only, every add-on's renewal period must match the base product's period, and at most 49 add-ons per purchase.

## **Keeping RevenueCat's paywall UI**

Now the case where RevenueCat draws the paywall. If the paywall you designed in the dashboard is what you want to render but you need your own code in the flow, you do not have to give up the UI.

The composable takes a single options object, and `dismissRequest` is a constructor parameter of the builder rather than a setter:

```
Paywall(
    PaywallOptions.Builder(dismissRequest = { navController.popBackStack() })
        .setOffering(offering)
        .setListener(paywallListener)
        .setShouldDisplayDismissButton(true)
        .build(),
)
```

`setOffering(null)` falls back to the current offering. `setShouldDisplayDismissButton` defaults to `false` here, while `PaywallDialogOptions` and the activity launch options default it to `true`, so an embedded paywall with no close button is the expected behavior rather than a bug.

### `**PaywallListener**`**: Observing the flow**

`PaywallListener` is an interface where every method has a default implementation, so you override only what you need:

```
private val paywallListener = object : PaywallListener {
    override fun onPurchaseCompleted(customerInfo: CustomerInfo, storeTransaction: StoreTransaction) {
        analytics.track("purchase_completed", storeTransaction.productIds)
        unlockContent(customerInfo)
    }

    override fun onPurchaseError(error: PurchasesError) {
        analytics.track("purchase_error", error.code.name)
    }

    override fun onPurchaseCancelled() {
        analytics.track("purchase_cancelled")
    }

    override fun onRestoreCompleted(customerInfo: CustomerInfo) {
        refreshUi(customerInfo)
    }
}
```

The full set is `onPurchaseStarted`, `onPurchaseCompleted`, `onPurchaseError`, `onPurchaseCancelled`, `onRestoreStarted`, `onRestoreCompleted`, `onRestoreError`, plus `onUrlOpened` for links inside the paywall and `onWebCheckoutOpened` for the case where the user left to pay on the web. That last one is distinct from cancellation and needs handling separately, because the user has not given up, they have gone elsewhere to finish. If you also plan to supply purchase logic, read the last section first: four of these callbacks go quiet in that mode.

### **Gating the flow before it starts**

The two hooks people want when they ask about customizing the purchase flow are not in the list above. They are the initiated callbacks, and they hold the flow open until you answer.

Each one receives a `Resumable`, a `fun interface` with `resume(shouldResume: Boolean)` and an `invoke` operator defaulting to true. So `resume()` proceeds and `resume(false)` aborts. If you never call it, the purchase never starts. Watch the import: the one `PaywallListener` wants is `com.revenuecat.purchases.ui.revenuecatui.utils.Resumable`. There is an identical `com.revenuecat.purchases.customercenter.Resumable` for Customer Center, and picking it gives you a confusing message about overriding nothing.

Both hooks are members of the same listener shown above:

```
override fun onPurchasePackageInitiated(rcPackage: Package, resume: Resumable) {
    if (userIsSignedIn()) {
        resume()
    } else {
        showSignInSheet(
            onSuccess = { resume() },
            onDismiss = { resume(false) },
        )
    }
}
```

`onRestoreInitiated(resume)` gives you the same gate for restore. This is the place to require sign in, ask for a confirmation, or check an age gate, because it runs before Google Play's sheet appears rather than after the user has paid.

### **Getting a result from a full screen paywall**

For a paywall presented as its own activity, results come back through `PaywallResultHandler`. Construct the launcher in `onCreate`. There is no `remember` based helper:

```
class MainActivity : ComponentActivity(), PaywallResultHandler {

    private lateinit var launcher: PaywallActivityLauncher

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        launcher = PaywallActivityLauncher(this, this)
    }

    override fun onActivityResult(result: PaywallResult) {
        when (result) {
            is PaywallResult.Purchased -> unlockContent(result.customerInfo)
            is PaywallResult.Restored -> refreshUi(result.customerInfo)
            is PaywallResult.Error -> showError(result.error)
            PaywallResult.Cancelled -> Unit
        }
    }
}
```

`PaywallResult` is a sealed class, so that `when` is exhaustive and a new case would break the build rather than fall through silently. A user cancellation arrives as `Cancelled` rather than as an `Error` carrying `PurchaseCancelledError`, because the activity maps that code before returning.

To launch it with a listener or custom purchase logic attached, use the options form:

```
@OptIn(ExperimentalPreviewRevenueCatUIPurchasesAPI::class)
fun showPaywall(offering: Offering?) {
    launcher.launchWithOptions(
        PaywallActivityLaunchOptions.Builder()
            .setOffering(offering)
            .setListener(paywallListener)
            .build(),
    )
}
```

The opt in is required only because of `setListener` and `setPurchaseLogic` on this particular builder. `Paywall()`, `PaywallOptions`, `PaywallListener`, and `PaywallPurchaseLogic` themselves need none. There is also `launchIfNeededWithOptions`, which takes a required entitlement identifier or a `shouldDisplayBlock` and skips presentation when the user already has access.

One caveat shows up only in the field. The SDK passes the listener and purchase logic to the activity through an in memory store rather than through the intent, because neither is serializable. If the process dies while the paywall is open, the SDK logs that they were lost, finishes the activity, and returns `PaywallResult.Cancelled`. Do not treat that result as a signal that the user declined. This applies only when you attached one of them. A paywall launched without either carries no in memory key and recreates normally.

### **Custom variables**

A dashboard paywall can contain `{{ custom.player_name }}` placeholders, and you supply the values:

```
PaywallOptions.Builder(dismissRequest = { dismiss() })
    .setCustomVariables(
        mapOf(
            "player_name" to CustomVariableValue.String("John"),
            "level" to CustomVariableValue.Number(42),
            "is_premium" to CustomVariableValue.Boolean(true),
        ),
    )
    .build()
```

Both `{{ custom.key }}` and `{{ $custom.key }}` resolve. Resolution order is the value you passed, then the dashboard default, then an empty string with a warning logged. Keys must start with a letter and contain only letters, digits, and underscores, and the SDK drops invalid keys with a warning rather than surfacing an error, so check logcat if a placeholder renders empty. One exception: `PaywallDialogOptions.Builder.setCustomVariables` skips the validator, so a bad key there is never logged and simply resolves to empty.

## **Owning the transaction with **`**PaywallPurchaseLogic**`

This is the case where your own code completes the Google Play transaction and RevenueCat supplies only the paywall and the backend. You configure `PurchasesAreCompletedBy.MY_APP` and hand the paywall your purchase code.

The interface has two suspend methods. The purchase side receives the activity and a params object:

```
class MyPurchaseLogic(
    private val billing: MyBillingClient,
) : PaywallPurchaseLogic {

    override suspend fun performPurchase(
        activity: Activity,
        params: PaywallPurchaseLogicParams,
    ): PurchaseLogicResult {
        return when (val outcome = billing.purchase(activity, params.rcPackage)) {
            is Outcome.Success -> PurchaseLogicResult.Success
            is Outcome.Cancelled -> PurchaseLogicResult.Cancellation
            is Outcome.Failed -> PurchaseLogicResult.Error(outcome.error)
        }
    }
```

Restore in this mode means telling RevenueCat what Google Play already knows, which syncPurchases does, so the implementation is usually thin:

```
    override suspend fun performRestore(customerInfo: CustomerInfo): PurchaseLogicResult {
        return try {
            Purchases.sharedInstance.awaitSyncPurchases()
            PurchaseLogicResult.Success
        } catch (e: PurchasesException) {
            PurchaseLogicResult.Error(e.error)
        }
    }
}
```

If your billing code is callback based, extend `PaywallPurchaseLogicWithCallback` and implement `performPurchaseWithCompletion` and `performRestoreWithCompletion` instead. The base class bridges them for you.

The result type has three cases and the exact names are easy to miss. It is `PurchaseLogicResult.Cancellation`, not `Cancelled`, and the error case carries a nullable `errorDetails`:

- `**Success**` triggers `syncPurchases`. From `performPurchase` it also dismisses the paywall. From `performRestore` it dismisses only if a `shouldDisplayBlock` was set and no longer matches.
- `**Cancellation**` tracks a cancel event and leaves the paywall open on the purchase path. On the restore path it is silently ignored.
- `**Error(errorDetails)**` shows an error dialog only if you provide the error. `PurchaseLogicResult.Error()` with no argument does nothing at all: no dialog, no tracking. Always pass the error.
`params` is a `PaywallPurchaseLogicParams`, which is what makes this interface usable for plan changes. It carries `rcPackage`, `subscriptionOption`, `oldProductId`, and `replacementMode`, so a product change configured on the dashboard paywall reaches your code with enough information to reproduce it. One type detail will bite you on the next line: `replacementMode` is typed `ReplacementMode?`, the interface that both `StoreReplacementMode` and the deprecated `GoogleReplacementMode` implement. There is no public converter, so narrow it yourself with `params.replacementMode as? StoreReplacementMode` before passing it to a `PurchaseParams.Builder`.

The older `PurchaseLogic` and `PurchaseLogicWithCallback` types are deprecated in favor of these, because their `performPurchase` took only a `Package` and had nowhere to put the product change details.

Three behaviors of this mode need internalizing before you ship it:

1. **The SDK no longer acknowledges purchases**, so your code must. The KDoc on `PurchasesAreCompletedBy.MY_APP` states the consequence: failing to acknowledge within 3 days leads Google Play to automatically refund the user.
1. `**MY_APP**`** with a null purchase logic does not fall back to normal behavior.** `validateState` puts the paywall into an error state that renders instead of your paywall.
1. **Four listener callbacks stop firing.** `onPurchaseStarted`, `onPurchaseCompleted`, `onRestoreStarted`, and `onRestoreCompleted` live only on the `REVENUECAT` branch. The initiated hooks still run because they execute before the branch, and `onPurchaseError`, `onPurchaseCancelled`, and `onRestoreError` can still fire because they come from a `catch` that wraps both branches. Anything you were doing in `onPurchaseCompleted` has to move into your `performPurchase` success path.
## **Choosing an approach**

- **Full design control, standard purchase handling**: `PurchaseParams` with `purchaseWith` or `awaitPurchase`.
- **Dashboard paywall embedded in your layout**: `Paywall` with `PaywallListener`.
- **Dashboard paywall as its own screen, returning a result**: `PaywallActivityLauncher` with `PaywallResult`.
- **Dashboard paywall, your own billing code**: `PurchasesAreCompletedBy.MY_APP` with `PaywallPurchaseLogic`.
The naming differences between these surfaces are not consistent, and the compiler will not always help. `PaywallOptions.Builder` has `setPurchaseLogic`, while `PaywallDialogOptions.Builder` calls the same thing `setCustomPurchaseLogic`. `PaywallResult` uses `Cancelled` while `PurchaseLogicResult` uses `Cancellation`. When something does not resolve, check which options class you are holding.

## **Conclusion**

The habit worth taking from all of this is to treat the silent defaults as decisions you are making. `WITHOUT_PRORATION` is what a product change does when you say nothing about proration, and it is the mode that charges an upgrading user nothing today. `defaultOption` is what gets purchased when you pass a `Package`, chosen by a documented rule you can predict. Both are reasonable, and both are wrong for some paywalls, so pick them on purpose and write the reason next to the builder call.

The other shift is to stop reading a declined purchase as a failed one. The SDK separates a failure from a decision at every layer, from the boolean on the error callback to the result types the paywall returns to the hooks that let you abort before Google Play ever appears. Your error handling should draw the same line, because the user who backed out of the sheet is still a prospect and does not need to be told that something went wrong.
