Handle flow & paywall events - Kotlin Multiplatform

This guide covers event handling for purchases, restorations, product selection, and flow rendering. You must also implement button handling (closing the flow, opening links, etc.). See our guide on handling flow actions for details.

Flows and paywalls configured with the Flow Builder or Paywall Builder don’t need extra code to make and restore purchases. However, they generate some events that your app can respond to. Those events include button presses (close buttons, URLs, product selections, and so on) as well as notifications on purchase-related actions. Learn how to respond to these events below.

To control or monitor processes occurring on the flow screen within your mobile app, implement the AdaptyUIFlowsEventsObserver interface methods and register your observer with AdaptyUI.setFlowsEventsObserver(). Some methods have default implementations that handle common scenarios automatically, so override only the methods you want to change:

AdaptyUI.setFlowsEventsObserver(object : AdaptyUIFlowsEventsObserver {
    // override only the methods you want to change
})

These methods are where you add your custom logic to respond to flow events. You can use view.dismiss() to close the flow, or implement any other custom behavior you need. Note that dismiss() is a suspend function — inside a callback, launch it on the observer’s mainUiScope: mainUiScope.launch { view.dismiss() }.

User-generated events

Flow appearance and disappearance

When a flow appears or disappears, these methods will be invoked:

override fun flowViewDidAppear(view: AdaptyUIFlowView) {
    // Handle flow appearance
    // You can track analytics or update UI here
}

override fun flowViewDidDisappear(view: AdaptyUIFlowView) {
    // Handle flow disappearance
    // You can track analytics or update UI here
}
  • On iOS, flowViewDidAppear is also invoked when a user taps the web paywall button inside a flow, and a web paywall opens in an in-app browser.
  • On iOS, flowViewDidDisappear is also invoked when a web paywall opened from a flow in an in-app browser disappears from the screen.
Event examples (Click to expand)
// Flow appeared
{
  // No additional data
}

// Flow disappeared
{
  // No additional data
}

Product selection

If a user selects a product for purchase, this method will be invoked:

override fun flowViewDidSelectProduct(view: AdaptyUIFlowView, productId: String) {
    // Handle product selection
    // You can update UI or track analytics here
}
Event example (Click to expand)
{
  "productId": "premium_monthly"
}

Started purchase

If a user initiates the purchase process, this method will be invoked:

override fun flowViewDidStartPurchase(view: AdaptyUIFlowView, product: AdaptyPaywallProduct) {
    // Handle purchase start
    // You can show loading indicators or track analytics here
}

In Observer mode, purchases started from a flow are delivered to your AdaptyUIObserverModeResolver instead.

Event example (Click to expand)
{
  "product": {
    "vendorProductId": "premium_monthly",
    "localizedTitle": "Premium Monthly",
    "localizedDescription": "Premium subscription for 1 month",
    "localizedPrice": "$9.99",
    "price": 9.99,
    "currencyCode": "USD"
  }
}

Successful, canceled, or pending purchase

If a purchase finishes, this method will be invoked. By default, it does nothing — the flow stays open after the purchase until you dismiss it, so call view.dismiss() yourself once the user gets access:

override fun flowViewDidFinishPurchase(
    view: AdaptyUIFlowView,
    product: AdaptyPaywallProduct,
    purchaseResult: AdaptyPurchaseResult
) {
    when (purchaseResult) {
        is AdaptyPurchaseResult.Success -> {
            // Check if user has access to premium features
            if (purchaseResult.profile.accessLevels["premium"]?.isActive == true) {
                mainUiScope.launch { view.dismiss() }
            }
        }
        AdaptyPurchaseResult.Pending -> {
            // Handle pending purchase (e.g., user will pay offline with cash)
        }
        AdaptyPurchaseResult.UserCanceled -> {
            // Handle user cancellation
        }
    }
}
Event examples (Click to expand)
// Successful purchase
{
  "product": {
    "vendorProductId": "premium_monthly",
    "localizedTitle": "Premium Monthly",
    "localizedDescription": "Premium subscription for 1 month",
    "localizedPrice": "$9.99",
    "price": 9.99,
    "currencyCode": "USD"
  },
  "purchaseResult": {
    "type": "Success",
    "profile": {
      "accessLevels": {
        "premium": {
          "id": "premium",
          "isActive": true,
          "expiresAt": "2024-02-15T10:30:00Z"
        }
      }
    }
  }
}

// Pending purchase
{
  "product": {
    "vendorProductId": "premium_monthly",
    "localizedTitle": "Premium Monthly",
    "localizedDescription": "Premium subscription for 1 month",
    "localizedPrice": "$9.99",
    "price": 9.99,
    "currencyCode": "USD"
  },
  "purchaseResult": {
    "type": "Pending"
  }
}

// User canceled purchase
{
  "product": {
    "vendorProductId": "premium_monthly",
    "localizedTitle": "Premium Monthly",
    "localizedDescription": "Premium subscription for 1 month",
    "localizedPrice": "$9.99",
    "price": 9.99,
    "currencyCode": "USD"
  },
  "purchaseResult": {
    "type": "UserCanceled"
  }
}

We recommend dismissing the flow screen in case of successful purchase.

Failed purchase

If a purchase fails due to an error, this method will be invoked. This includes StoreKit/Google Play Billing errors (payment restrictions, invalid products, network failures), transaction verification failures, and system errors. Note that user cancellations trigger flowViewDidFinishPurchase with a cancelled result instead, and pending payments do not trigger this method.

override fun flowViewDidFailPurchase(
    view: AdaptyUIFlowView,
    product: AdaptyPaywallProduct,
    error: AdaptyError
) {
    // Add your purchase failure handling logic here
    // For example: show error message, retry option, or custom error handling
}
Event example (Click to expand)
{
  "product": {
    "vendorProductId": "premium_monthly",
    "localizedTitle": "Premium Monthly",
    "localizedDescription": "Premium subscription for 1 month",
    "localizedPrice": "$9.99",
    "price": 9.99,
    "currencyCode": "USD"
  },
  "error": {
    "code": "purchase_failed",
    "message": "Purchase failed due to insufficient funds",
    "details": {
      "underlyingError": "Insufficient funds in account"
    }
  }
}

Started restore

If a user initiates the restore process, this method will be invoked:

override fun flowViewDidStartRestore(view: AdaptyUIFlowView) {
    // Handle restore start
    // You can show loading indicators or track analytics here
}

Successful restore

If restoring a purchase succeeds, this method will be invoked. By default, it does nothing — the flow stays open after the restore until you dismiss it:

override fun flowViewDidFinishRestore(view: AdaptyUIFlowView, profile: AdaptyProfile) {
    // Add your successful restore handling logic here
    // For example: show success message, update UI, or dismiss the flow

    // Check if user has access to premium features
    if (profile.accessLevels["premium"]?.isActive == true) {
        mainUiScope.launch { view.dismiss() }
    }
}
Event example (Click to expand)
{
  "profile": {
    "accessLevels": {
      "premium": {
        "id": "premium",
        "isActive": true,
        "expiresAt": "2024-02-15T10:30:00Z"
      }
    },
    "subscriptions": [
      {
        "vendorProductId": "premium_monthly",
        "isActive": true,
        "expiresAt": "2024-02-15T10:30:00Z"
      }
    ]
  }
}

We recommend dismissing the screen if the user has the required accessLevel. Refer to the Subscription status topic to learn how to check it.

Failed restore

If Adapty.restorePurchases() fails, this method will be invoked:

override fun flowViewDidFailRestore(view: AdaptyUIFlowView, error: AdaptyError) {
    // Add your restore failure handling logic here
    // For example: show error message, retry option, or custom error handling
}
Event example (Click to expand)
{
  "error": {
    "code": "restore_failed",
    "message": "Purchase restoration failed",
    "details": {
      "underlyingError": "No previous purchases found"
    }
  }
}

Web payment navigation completion

If a user initiates the purchase process using a web paywall, this method will be invoked:

override fun flowViewDidFinishWebPaymentNavigation(
    view: AdaptyUIFlowView,
    product: AdaptyPaywallProduct?,
    error: AdaptyError?
) {
    if (error != null) {
        // Handle web payment navigation error
    } else {
        // Handle successful web payment navigation
    }
}
Event examples (Click to expand)
// Successful web payment navigation
{
  "product": {
    "vendorProductId": "premium_monthly",
    "localizedTitle": "Premium Monthly",
    "localizedDescription": "Premium subscription for 1 month",
    "localizedPrice": "$9.99",
    "price": 9.99,
    "currencyCode": "USD"
  },
  "error": null
}

// Failed web payment navigation
{
  "product": null,
  "error": {
    "code": "web_payment_failed",
    "message": "Web payment navigation failed",
    "details": {
      "underlyingError": "Network connection error"
    }
  }
}

Data fetching and rendering

Product loading errors

If you don’t pass the products during the initialization, AdaptyUI will retrieve the necessary objects from the server by itself. If this operation fails, AdaptyUI will report the error by calling this method:

override fun flowViewDidFailLoadingProducts(view: AdaptyUIFlowView, error: AdaptyError) {
    // Add your product loading failure handling logic here
    // For example: show error message, retry option, or custom error handling
}
Event example (Click to expand)
{
  "error": {
    "code": "products_loading_failed",
    "message": "Failed to load products from the server",
    "details": {
      "underlyingError": "Network timeout"
    }
  }
}

Rendering and runtime errors

If an error occurs during the interface rendering, or another non-purchase runtime error occurs, it will be reported by this method. By default, the flow is dismissed on error — override the method to keep it open or add your own handling:

override fun flowViewDidReceiveError(view: AdaptyUIFlowView, error: AdaptyError) {
    // Handle the error
    // The default implementation dismisses the flow;
    // once you override this method, dismissal is up to you
}
Event example (Click to expand)
{
    "error": {
        "code": "rendering_failed",
            "message": "Failed to render flow interface",
            "details": {
            "underlyingError": "Invalid flow configuration"
        }
    }
}

In a normal situation, such errors should not occur, so if you come across one, please let us know.

Analytics events

The flowViewDidReceiveAnalyticEvent callback is reserved for custom analytic events from a flow. Flows don’t emit these events to your code yet, so you don’t need to implement it:

override fun flowViewDidReceiveAnalyticEvent(
    view: AdaptyUIFlowView,
    name: String,
    paramsJsonString: String
) {
    // Reserved for custom analytic events from a flow
}

Android system back button

By default, a flow can’t be dismissed with the Android system back button or back gesture — the default flowViewDidPerformAction implementation dismisses the flow only on CloseAction and ignores AndroidSystemBackAction, so the user leaves the flow through a path you define, such as a Close button or an on_device_back action in the builder. If you want the system back button to dismiss the flow, handle the action yourself:

override fun flowViewDidPerformAction(view: AdaptyUIFlowView, action: AdaptyUIAction) {
    when (action) {
        is AdaptyUIAction.CloseAction ->
            mainUiScope.launch { view.dismiss() } // default behavior
        is AdaptyUIAction.AndroidSystemBackAction ->
            mainUiScope.launch { view.dismiss() } // not handled by default
        is AdaptyUIAction.OpenUrlAction ->
            AdaptyUI.openWebUrl(action.url, action.openIn) // default behavior
        else -> Unit
    }
}

See the guide on handling flow actions for the full list of actions.

Paywalls configured with the Paywall Builder don’t need extra code to make and restore purchases. However, they generate some events that your app can respond to. Those events include button presses (close buttons, URLs, product selections, and so on) as well as notifications on purchase-related actions taken on the paywall. Learn how to respond to these events below.

This guide is for new Paywall Builder paywalls only.

To control or monitor processes occurring on the paywall screen within your mobile app, implement the AdaptyUIPaywallsEventsObserver interface methods. Some methods have default implementations that handle common scenarios automatically.

These methods are where you add your custom logic to respond to paywall events. You can use view.dismiss() to close the paywall, or implement any other custom behavior you need.

User-generated events

Paywall appearance and disappearance

When a paywall appears or disappears, these methods will be invoked:

override fun paywallViewDidAppear(view: AdaptyUIPaywallView) {
    // Handle paywall appearance
    // You can track analytics or update UI here
}

override fun paywallViewDidDisappear(view: AdaptyUIPaywallView) {
    // Handle paywall disappearance
    // You can track analytics or update UI here
}
  • On iOS, paywallViewDidAppear is also invoked when a user taps the web paywall button inside a paywall, and a web paywall opens in an in-app browser.
  • On iOS, paywallViewDidDisappear is also invoked when a web paywall opened from a paywall in an in-app browser disappears from the screen.
Event examples (Click to expand)
// Paywall appeared
{
  // No additional data
}

// Paywall disappeared
{
  // No additional data
}

Product selection

If a user selects a product for purchase, this method will be invoked:

override fun paywallViewDidSelectProduct(view: AdaptyUIPaywallView, productId: String) {
    // Handle product selection
    // You can update UI or track analytics here
}
Event example (Click to expand)
{
  "productId": "premium_monthly"
}

Started purchase

If a user initiates the purchase process, this method will be invoked:

override fun paywallViewDidStartPurchase(view: AdaptyUIPaywallView, product: AdaptyPaywallProduct) {
    // Handle purchase start
    // You can show loading indicators or track analytics here
}
Event example (Click to expand)
{
  "product": {
    "vendorProductId": "premium_monthly",
    "localizedTitle": "Premium Monthly",
    "localizedDescription": "Premium subscription for 1 month",
    "localizedPrice": "$9.99",
    "price": 9.99,
    "currencyCode": "USD"
  }
}

Successful, canceled, or pending purchase

If a purchase succeeds, this method will be invoked. By default, it automatically dismisses the paywall unless the purchase was canceled by the user:

override fun paywallViewDidFinishPurchase(
    view: AdaptyUIPaywallView,
    product: AdaptyPaywallProduct,
    purchaseResult: AdaptyPurchaseResult
) {
    when (purchaseResult) {
        is AdaptyPurchaseResult.Success -> {
            // Check if user has access to premium features
            if (purchaseResult.profile.accessLevels["premium"]?.isActive == true) {
                view.dismiss()
            }
        }
        AdaptyPurchaseResult.Pending -> {
            // Handle pending purchase (e.g., user will pay offline with cash)
        }
        AdaptyPurchaseResult.UserCanceled -> {
            // Handle user cancellation
        }
    }
}
Event examples (Click to expand)
// Successful purchase
{
  "product": {
    "vendorProductId": "premium_monthly",
    "localizedTitle": "Premium Monthly",
    "localizedDescription": "Premium subscription for 1 month",
    "localizedPrice": "$9.99",
    "price": 9.99,
    "currencyCode": "USD"
  },
  "purchaseResult": {
    "type": "Success",
    "profile": {
      "accessLevels": {
        "premium": {
          "id": "premium",
          "isActive": true,
          "expiresAt": "2024-02-15T10:30:00Z"
        }
      }
    }
  }
}

// Pending purchase
{
  "product": {
    "vendorProductId": "premium_monthly",
    "localizedTitle": "Premium Monthly",
    "localizedDescription": "Premium subscription for 1 month",
    "localizedPrice": "$9.99",
    "price": 9.99,
    "currencyCode": "USD"
  },
  "purchaseResult": {
    "type": "Pending"
  }
}

// User canceled purchase
{
  "product": {
    "vendorProductId": "premium_monthly",
    "localizedTitle": "Premium Monthly",
    "localizedDescription": "Premium subscription for 1 month",
    "localizedPrice": "$9.99",
    "price": 9.99,
    "currencyCode": "USD"
  },
  "purchaseResult": {
    "type": "UserCanceled"
  }
}

We recommend dismissing the paywall screen in case of successful purchase.

Failed purchase

If a purchase fails due to an error, this method will be invoked. This includes StoreKit/Google Play Billing errors (payment restrictions, invalid products, network failures), transaction verification failures, and system errors. Note that user cancellations trigger paywallViewDidFinishPurchase with a cancelled result instead, and pending payments do not trigger this method.

override fun paywallViewDidFailPurchase(
    view: AdaptyUIPaywallView,
    product: AdaptyPaywallProduct,
    error: AdaptyError
) {
    // Add your purchase failure handling logic here
    // For example: show error message, retry option, or custom error handling
}
Event example (Click to expand)
{
  "product": {
    "vendorProductId": "premium_monthly",
    "localizedTitle": "Premium Monthly",
    "localizedDescription": "Premium subscription for 1 month",
    "localizedPrice": "$9.99",
    "price": 9.99,
    "currencyCode": "USD"
  },
  "error": {
    "code": "purchase_failed",
    "message": "Purchase failed due to insufficient funds",
    "details": {
      "underlyingError": "Insufficient funds in account"
    }
  }
}

Started restore

If a user initiates the restore process, this method will be invoked:

override fun paywallViewDidStartRestore(view: AdaptyUIPaywallView) {
    // Handle restore start
    // You can show loading indicators or track analytics here
}

Successful restore

If restoring a purchase succeeds, this method will be invoked:

override fun paywallViewDidFinishRestore(view: AdaptyUIPaywallView, profile: AdaptyProfile) {
    // Add your successful restore handling logic here
    // For example: show success message, update UI, or dismiss paywall
    
    // Check if user has access to premium features
    if (profile.accessLevels["premium"]?.isActive == true) {
        view.dismiss()
    }
}
Event example (Click to expand)
{
  "profile": {
    "accessLevels": {
      "premium": {
        "id": "premium",
        "isActive": true,
        "expiresAt": "2024-02-15T10:30:00Z"
      }
    },
    "subscriptions": [
      {
        "vendorProductId": "premium_monthly",
        "isActive": true,
        "expiresAt": "2024-02-15T10:30:00Z"
      }
    ]
  }
}

We recommend dismissing the screen if the user has the required accessLevel. Refer to the Subscription status topic to learn how to check it.

Failed restore

If Adapty.restorePurchases() fails, this method will be invoked:

override fun paywallViewDidFailRestore(view: AdaptyUIPaywallView, error: AdaptyError) {
    // Add your restore failure handling logic here
    // For example: show error message, retry option, or custom error handling
}
Event example (Click to expand)
{
  "error": {
    "code": "restore_failed",
    "message": "Purchase restoration failed",
    "details": {
      "underlyingError": "No previous purchases found"
    }
  }
}

Web payment navigation completion

If a user initiates the purchase process using a web paywall, this method will be invoked:

override fun paywallViewDidFinishWebPaymentNavigation(
    view: AdaptyUIPaywallView,
    product: AdaptyPaywallProduct?,
    error: AdaptyError?
) {
    if (error != null) {
        // Handle web payment navigation error
    } else {
        // Handle successful web payment navigation
    }
}
Event examples (Click to expand)
// Successful web payment navigation
{
  "product": {
    "vendorProductId": "premium_monthly",
    "localizedTitle": "Premium Monthly",
    "localizedDescription": "Premium subscription for 1 month",
    "localizedPrice": "$9.99",
    "price": 9.99,
    "currencyCode": "USD"
  },
  "error": null
}

// Failed web payment navigation
{
  "product": null,
  "error": {
    "code": "web_payment_failed",
    "message": "Web payment navigation failed",
    "details": {
      "underlyingError": "Network connection error"
    }
  }
}

Data fetching and rendering

Product loading errors

If you don’t pass the products during the initialization, AdaptyUI will retrieve the necessary objects from the server by itself. If this operation fails, AdaptyUI will report the error by calling this method:

override fun paywallViewDidFailLoadingProducts(view: AdaptyUIPaywallView, error: AdaptyError) {
    // Add your product loading failure handling logic here
    // For example: show error message, retry option, or custom error handling
}
Event example (Click to expand)
{
  "error": {
    "code": "products_loading_failed",
    "message": "Failed to load products from the server",
    "details": {
      "underlyingError": "Network timeout"
    }
  }
}

Rendering errors

If an error occurs during the interface rendering, it will be reported by this method:

override fun paywallViewDidFailRendering(view: AdaptyUIPaywallView, error: AdaptyError) {
    // Handle rendering error
    // In a normal situation, such errors should not occur
    // If you come across one, please let us know
}
Event example (Click to expand)
{
    "error": {
        "code": "rendering_failed",
            "message": "Failed to render paywall interface",
            "details": {
            "underlyingError": "Invalid paywall configuration"
        }
    }
}

In a normal situation, such errors should not occur, so if you come across one, please let us know.