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: PaywallOptionsPaywallListenerPaywallActivityLauncher, 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 purchasePurchaseParams and Purchases.purchase.
  • RevenueCat's paywall UI, RevenueCat completing the purchasePaywallOptions and PaywallListener.
  • RevenueCat's paywall UI, your own code completing the purchasePaywallOptions 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.monthlyoffering.annualoffering.weeklyoffering.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 freePhaseintroPhase, 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_TRIALSINGLE_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.
  2. PurchaseParams.Builder(activity, storeProduct) takes a StoreProduct.
  3. 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 equalshashCode, 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 errorcode, 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 NORMALINTROTRIALPREPAID.
  • 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.

Choosing a mode

  • Upgrade, charge nowCHARGE_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, generousWITH_TIME_PRORATION. The user pays nothing today and their unused time becomes free days on the higher tier.
  • DowngradeDEFERRED. 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 priceWITH_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 onPurchaseStartedonPurchaseCompletedonPurchaseErroronPurchaseCancelledonRestoreStartedonRestoreCompletedonRestoreError, 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()PaywallOptionsPaywallListener, 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 rcPackagesubscriptionOptionoldProductId, 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.
  2. 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.
  3. Four listener callbacks stop firing. onPurchaseStartedonPurchaseCompletedonRestoreStarted, and onRestoreCompleted live only on the REVENUECAT branch. The initiated hooks still run because they execute before the branch, and onPurchaseErroronPurchaseCancelled, 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 handlingPurchaseParams with purchaseWith or awaitPurchase.
  • Dashboard paywall embedded in your layoutPaywall with PaywallListener.
  • Dashboard paywall as its own screen, returning a resultPaywallActivityLauncher with PaywallResult.
  • Dashboard paywall, your own billing codePurchasesAreCompletedBy.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 setCustomPurchaseLogicPaywallResult 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.