> ## Documentation Index
> Fetch the complete documentation index at: https://docs.appdna.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Android Paywalls

> Present server-driven paywalls with purchase handling

<Info>
  **Supported on:** Android SDK `1.0.33+`
</Info>

<Tip>
  You can A/B test this paywall with no extra code — create an experiment on it in the Console and the SDK serves the assigned variant automatically. See [Servable Surface Experiments](/sdks/android/experiments#servable-surface-experiments).
</Tip>

The AppDNA paywall module lets you present server-driven paywalls configured in the AppDNA Console. Paywalls are rendered natively with Jetpack Compose and include built-in purchase handling through the billing module.

## Present a Paywall

Present a paywall by its identifier:

```kotlin theme={null}
import ai.appdna.sdk.AppDNA
import ai.appdna.sdk.paywalls.PaywallContext

AppDNA.presentPaywall(
    activity = this,
    id = "premium_paywall",
    context = PaywallContext(placement = "settings"),
    listener = paywallDelegate,
)
```

## Present by Placement

Present a paywall by placement — the SDK uses audience rules configured in the Console to determine which paywall to show:

```kotlin theme={null}
// Present paywall by placement (audience rules determine which paywall to show)
AppDNA.presentPaywallByPlacement(
    activity = this,
    placement = "premium_upgrade",
)
```

`presentPaywallByPlacement` is a top-level method on `AppDNA` (not on `AppDNA.paywall`).

## Module Access

Access the paywall module directly:

```kotlin theme={null}
val paywall = AppDNA.paywall
```

### Module Methods

| Method        | Signature                                                                           | Description                                                     |
| ------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `present`     | `present(activity: Activity, paywallId: String, context: PaywallContext?): Boolean` | Present a paywall by ID. Returns `false` when nothing was shown |
| `setDelegate` | `setDelegate(delegate: AppDNAPaywallDelegate?)`                                     | Set a delegate for paywall callbacks                            |

The placement-routed flavor lives on the top-level `AppDNA` object as `AppDNA.presentPaywallByPlacement(activity, placement, context)`.

## PaywallContext

Provide context about where and why the paywall is being shown:

```kotlin theme={null}
val context = PaywallContext(
    placement = "settings",
    experiment = "paywall_test",
    variant = "b",
)
```

| Property     | Type      | Description                                                                      |
| ------------ | --------- | -------------------------------------------------------------------------------- |
| `placement`  | `String`  | Where the paywall is triggered (e.g., "settings", "onboarding", "feature\_gate") |
| `experiment` | `String?` | Experiment identifier, if showing as part of an A/B test                         |
| `variant`    | `String?` | Variant identifier within the experiment                                         |

<Info>
  The `placement` value is included in all paywall analytics events, allowing you to measure conversion by placement in the Console.
</Info>

## AppDNAPaywallDelegate

Implement the delegate interface to receive paywall lifecycle callbacks. **The same delegate fires for paywalls launched standalone via `AppDNA.paywall.present(...)` AND for paywalls launched from inside an onboarding flow** (via the `present_paywall` step outcome) — register once with `AppDNA.paywall.setDelegate(...)` and handle every paywall presentation through the same handler.

```kotlin theme={null}
interface AppDNAPaywallDelegate {
    fun onPaywallPresented(paywallId: String)
    fun onPaywallAction(paywallId: String, action: PaywallAction)
    fun onPaywallPurchaseStarted(paywallId: String, productId: String)
    fun onPaywallPurchaseCompleted(paywallId: String, productId: String, transaction: TransactionInfo)
    fun onPaywallPurchaseFailed(paywallId: String, error: Throwable)
    fun onPaywallDismissed(paywallId: String)
    fun onPromoCodeSubmit(paywallId: String, code: String, completion: (Boolean) -> Unit)
    fun onPostPurchaseDeepLink(paywallId: String, url: String)
    fun onPostPurchaseNextStep(paywallId: String)
    fun onPaywallRestoreStarted(paywallId: String)
    fun onPaywallRestoreCompleted(paywallId: String, productIds: List<String>)
    fun onPaywallRestoreFailed(paywallId: String, error: Throwable)
}
```

### Example Implementation

```kotlin theme={null}
class PaywallHandler : AppDNAPaywallDelegate {

    override fun onPaywallPresented(paywallId: String) {
        Log.d("Paywall", "Paywall shown: $paywallId")
    }

    override fun onPaywallAction(paywallId: String, action: PaywallAction) {
        when (action) {
            PaywallAction.CTA_TAPPED -> Log.d("Paywall", "CTA tapped")
            PaywallAction.FEATURE_SELECTED -> Log.d("Paywall", "Feature selected")
            PaywallAction.PLAN_CHANGED -> Log.d("Paywall", "Plan changed")
            PaywallAction.LINK_TAPPED -> Log.d("Paywall", "Link tapped")
            PaywallAction.CUSTOM -> Log.d("Paywall", "Custom action")
        }
    }

    override fun onPaywallPurchaseStarted(paywallId: String, productId: String) {
        Log.d("Paywall", "Purchase started: $productId")
    }

    override fun onPaywallPurchaseCompleted(
        paywallId: String,
        productId: String,
        transaction: TransactionInfo,
    ) {
        Log.d("Paywall", "Purchased $productId — txn: ${transaction.transactionId}")
        // Paywall auto-dismisses on successful purchase
    }

    override fun onPaywallPurchaseFailed(paywallId: String, error: Throwable) {
        Log.e("Paywall", "Purchase failed: ${error.message}", error)
        // Paywall stays visible so the user can retry
    }

    override fun onPaywallDismissed(paywallId: String) {
        Log.d("Paywall", "Paywall dismissed")
    }

    override fun onPaywallRestoreStarted(paywallId: String) {
        // Show a "Restoring purchases…" toast or spinner
    }

    override fun onPaywallRestoreCompleted(paywallId: String, productIds: List<String>) {
        if (productIds.isEmpty()) {
            // Tell the user there were no purchases to restore
        } else {
            // Refresh entitlements / unlock premium features
        }
    }

    override fun onPaywallRestoreFailed(paywallId: String, error: Throwable) {
        // Surface an error toast — paywall stays visible so the user can retry
    }
}
```

## Restore Purchases

The Restore button is rendered alongside the **CTA section** (above or below the main subscribe button, controlled by the section's `restore_text` and `restore_position` config in the Console). When the user taps it, the SDK runs the restore flow and fires the lifecycle:

1. SDK fires `onPaywallRestoreStarted(paywallId)` immediately.
2. SDK calls `BillingClient.queryPurchasesAsync(SUBS)` + `queryPurchasesAsync(INAPP)` and queries your verification endpoint for previously-purchased products (both subscriptions and one-time products are restorable).
3. On success: `onPaywallRestoreCompleted(paywallId, productIds)` fires with the list of restored product identifiers, and the SDK emits a `purchase_restored` analytics event automatically.
4. On failure (network error, no previous purchases, Play Billing error): `onPaywallRestoreFailed(paywallId, error)` fires and the SDK emits a `purchase_restore_failed` event.

A successful restore that returns at least one product **auto-dismisses** the paywall by default (`shouldDismiss = productIds.isNotEmpty() && !hostRequestedSkip`) — the user is now entitled, so there is nothing left to buy. The paywall stays visible only when the restore returns no products, fails, or the host opted out of auto-dismiss via `skipNextAutoDismissOnRestore`.

<Info>
  For richer transaction details (status, expiry, store), call `AppDNA.billing.getEntitlements()` after a successful restore — it returns `List<Entitlement>` with the full verified state. The paywall delegate intentionally returns just product IDs to keep the callback lightweight.
</Info>

## PaywallAction

The `PaywallAction` enum represents user interactions within the paywall:

| Value              | Description                                     |
| ------------------ | ----------------------------------------------- |
| `CTA_TAPPED`       | The main call-to-action button was tapped       |
| `FEATURE_SELECTED` | A feature item was selected                     |
| `PLAN_CHANGED`     | The user switched between plan options          |
| `LINK_TAPPED`      | A link (e.g., terms, privacy policy) was tapped |
| `CUSTOM`           | A custom action defined in the paywall config   |

## DismissReason

The `DismissReason` enum indicates how the paywall was closed. The wire-format string (sent in `paywall_close` analytics) is shown in the right column — same value across iOS, Android, Flutter, and React Native.

| Value             | Wire value          | Description                                                                                 |
| ----------------- | ------------------- | ------------------------------------------------------------------------------------------- |
| `PURCHASED`       | `"purchased"`       | Dismissed after a successful purchase                                                       |
| `RESTORE_SUCCESS` | `"restore_success"` | Dismissed after a restore returned at least one product (routes onboarding to success path) |
| `DISMISSED`       | `"dismissed"`       | Dismissed by the user via a close button                                                    |
| `TAPPED_OUTSIDE`  | `"tappedOutside"`   | Dismissed by tapping outside the paywall                                                    |
| `PROGRAMMATIC`    | `"programmatic"`    | Dismissed programmatically by your code                                                     |

## Paywall Sections

Paywalls configured in the Console support the following content sections:

| Section          | Description                                               |
| ---------------- | --------------------------------------------------------- |
| Header           | Title, subtitle, and optional hero image                  |
| Features list    | List of feature highlights with icons and descriptions    |
| Plan selection   | Selectable plan options (e.g., monthly, annual)           |
| CTA button       | Primary purchase button with dynamic price text           |
| Social proof     | Testimonials, ratings, or user counts                     |
| Guarantee        | Money-back guarantee or free trial messaging              |
| Image            | Full-width or sized image with optional corner radius     |
| Spacer           | Configurable vertical spacing between sections            |
| Testimonial      | Quote with author name, role, and optional avatar         |
| Countdown        | Urgency countdown timer with configurable expiry behavior |
| Legal            | Terms of service, privacy policy, and subscription terms  |
| Divider          | Horizontal separator line with optional label             |
| Sticky footer    | Fixed bottom bar with CTA and price summary               |
| Card             | Rounded card container for grouping related content       |
| Carousel         | Horizontally scrollable content cards (features, reviews) |
| Timeline         | Step-by-step vertical timeline (e.g., trial-to-paid flow) |
| Icon grid        | Grid layout of icons with labels (feature highlights)     |
| Comparison table | Side-by-side plan comparison (free vs. premium columns)   |
| Promo input      | Text field for entering promotional or coupon codes       |
| Toggle           | On/off toggle for add-on options (e.g., annual billing)   |
| Reviews carousel | Horizontally scrollable user reviews with ratings         |

### Plan Display Styles

The plan selection section supports the following display styles, configured per paywall in the Console as a lowercase `plan_display_style` string:

| Style                                                                                                    | Description                                                      |
| -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| `vertical_stack`                                                                                         | Stacked rows, each showing plan name, price, and badge. Default. |
| `radio_list`                                                                                             | Radio-button list with plan details per row.                     |
| `accordion`                                                                                              | Expandable/collapsible plan sections with full details.          |
| `horizontal_scroll`                                                                                      | Horizontally scrollable plan cards.                              |
| `carousel_cards` (alias `carousel`)                                                                      | Snap-paged plan cards.                                           |
| `pill_selector`                                                                                          | Rounded pill buttons arranged horizontally.                      |
| `segmented_toggle`                                                                                       | Two-/three-option segmented control (e.g., Monthly / Annual).    |
| `toggle_cards`                                                                                           | Card-style toggles, one selectable card per plan.                |
| `minimal_chips`                                                                                          | Compact chip-style plan picker with no card surround.            |
| `tiered_slider`                                                                                          | Slider that snaps between price tiers.                           |
| `feature_comparison` (aliases `pricing_table`, `comparison_table`, `comparison_cards`, `feature_matrix`) | Side-by-side plan comparison with checkmarked feature rows.      |

#### Card & Badge Customization

Plan cards support per-plan styling configured in the Console:

* **Badge text and color** — e.g., "Best Value", "Most Popular" with custom background
* **Card border and shadow** — highlight the selected or recommended plan
* **Save percentage** — automatically calculated and displayed on annual plans
* **Trial label** — shows "7-day free trial" or custom trial messaging
* **Card background** — solid color, gradient, or image per plan card
* **Corner radius** — configurable per card

### Layout Types

| Layout       | Description                                              |
| ------------ | -------------------------------------------------------- |
| `"stack"`    | Vertical stack layout (sections arranged top to bottom)  |
| `"grid"`     | Grid layout (for feature comparison or multi-plan views) |
| `"carousel"` | Horizontally scrollable section layout                   |

### Rich Media in Paywalls

Paywall sections support rich media content configured in the Console:

* **Lottie animations** — animated hero images, feature illustrations, or backgrounds
* **Rive animations** — interactive state-machine-driven animations
* **Video** — inline video in header or feature sections
* **Haptic feedback** — triggered on plan selection and CTA taps
* **Particle effects** — confetti or celebration effects on purchase completion
* **Per-section styling** — background colors, gradients, images, borders, shadows, and corner radius

See the [Rich Media](/sdks/android/rich-media) guide for full details on supported formats and configuration.

## Promo Code Handling

When a paywall includes a `Promo input` section, implement the promo code delegate method to validate codes against your backend. The completion callback is a simple `(Boolean) -> Unit` — `true` accepts the code, `false` rejects it (the SDK renders the in-built rejection message):

```kotlin theme={null}
override fun onPromoCodeSubmit(
    paywallId: String,
    code: String,
    completion: (Boolean) -> Unit,
) {
    lifecycleScope.launch {
        try {
            val isValid = MyAPI.validatePromoCode(code)
            completion(isValid)
        } catch (e: Exception) {
            completion(false)
        }
    }
}
```

## Auto-Tracked Events

The paywall module automatically tracks the following events:

| Event                     | Triggered When                                    |
| ------------------------- | ------------------------------------------------- |
| `paywall_view`            | Paywall is presented to the user                  |
| `paywall_close`           | Paywall is dismissed                              |
| `purchase_started`        | User initiates a purchase from the paywall        |
| `purchase_restored`       | A successful restore returns at least one product |
| `purchase_restore_failed` | The restore flow fails or returns no products     |

<Warning>
  Purchase completion and failure events are tracked by the billing module, not the paywall module. See the [Billing](/sdks/android/billing) documentation for the full list of purchase-related events.
</Warning>

## Configuration in Console

Paywalls are created and managed in the AppDNA Console:

1. Navigate to **Monetization > Paywalls**.
2. Create a new paywall or edit an existing one.
3. Add sections (header, features, plans, CTA, social proof, guarantee).
4. Link Google Play products to the plan options.
5. Optionally assign the paywall to an experiment for A/B testing.
6. Publish the paywall to make it available via the config bundle.

<Warning>
  Ensure the product identifiers used in your paywall match the products configured in Google Play Console. Mismatched identifiers will cause purchase failures.
</Warning>

## Full Example

```kotlin theme={null}
import ai.appdna.sdk.AppDNA
import ai.appdna.sdk.paywalls.AppDNAPaywallDelegate
import ai.appdna.sdk.paywalls.PaywallAction
import ai.appdna.sdk.paywalls.PaywallContext
import ai.appdna.sdk.TransactionInfo
import androidx.fragment.app.FragmentActivity

class PremiumGate(
    private val activity: FragmentActivity,
) : AppDNAPaywallDelegate {

    init {
        AppDNA.paywall.setDelegate(this)
    }

    fun showPremiumPaywall(placement: String) {
        AppDNA.presentPaywall(
            activity = activity,
            id = "premium_paywall",
            context = PaywallContext(placement = placement),
            listener = this,
        )
    }

    // MARK: AppDNAPaywallDelegate

    override fun onPaywallPresented(paywallId: String) {
        // Track additional analytics if needed
    }

    override fun onPaywallAction(paywallId: String, action: PaywallAction) {
        // Handle custom actions
    }

    override fun onPaywallPurchaseStarted(paywallId: String, productId: String) {
        // Show loading state if desired
    }

    override fun onPaywallPurchaseCompleted(
        paywallId: String,
        productId: String,
        transaction: TransactionInfo,
    ) {
        unlockPremium()
    }

    override fun onPaywallPurchaseFailed(paywallId: String, error: Throwable) {
        // Error is shown in the paywall UI automatically
    }

    override fun onPaywallDismissed(paywallId: String) {
        // User closed without purchasing
    }

    private fun unlockPremium() { /* ... */ }
}
```

<Note>
  The paywall module integrates with the billing module for purchase handling. See the [Billing](/sdks/android/billing) guide for the full purchase API.
</Note>

## Next Steps

* Set up [Billing](/sdks/android/billing) for detailed purchase handling
* Configure [Onboarding](/sdks/android/onboarding) flows that transition into paywalls
* Learn about [Offline Support](/sdks/android/offline) for paywall config caching
