📘 Paywall SDK – Tài liệu sử dụng TỪ A Z (kèm JSON mẫu)

Published: (January 8, 2026 at 11:24 PM EST)
4 min read
Source: Dev.to

Source: Dev.to

Paywall SDK – Quick Reference

Target: Android Kotlin (internal & production apps)
Scope:

  • JSON config structure
  • Parsing & data provision
  • UI rendering (Fragment / Dialog)
  • Purchase, sale & retention flows
  • Conversion‑optimising best practices

Architecture Flow

Remote Config / API / Local JSON

   PaywallDataProvider

    PaywallRepository

BasePaywallFragment / BasePaywallDialog

   Paywall UI (Full / One / Dialog)

Example JSON Config

{
  "uiType": "paywall_full_01",
  "ctaId": 0,
  "enable": true,
  "timesOpen": 0,
  "delayButtonX": 2,
  "timesFree": "1,2,9,10,14",
  "showAdsClose": false,
  "packages": [
    {
      "packageId": "test.com.year.discount",
      "packageIdOrigin": "test.com.yearly",
      "packageType": "yearly",
      "ctaId": 1,
      "isPopular": false,
      "isSelected": false
    },
    {
      "packageId": "test.com.month.discount",
      "packageIdOrigin": "test.com.monthly",
      "packageType": "weekly",
      "ctaId": 3,
      "isPopular": false,
      "isSelected": false
    },
    {
      "packageId": "test.com.week.offer",
      "packageType": "weekly",
      "ctaId": 2,
      "isPopular": true,
      "isSelected": true
    }
  ]
}

Field Definitions

Paywall‑level fields

FieldMeaning
uiTypeUI mapping (e.g., paywall_full_01, paywall_dialog_01)
ctaIdDefault CTA when no package is selected
enableTurn the paywall on/off
timesOpenNumber of app launches before showing (0 = always)
delayButtonXDelay (seconds) before the close button appears
timesFreeComma‑separated list of launches that are free
showAdsCloseAllow closing the paywall via an ad

Package‑level fields

FieldMeaning
packageIdSKU that is sold / active
packageIdOriginOriginal SKU (used to calculate discount)
packageTypeweekly / monthly / yearly / lifetime
ctaIdCTA specific to this package
isPopularShow “Popular” badge
isSelectedAuto‑select when the paywall opens

Parsing Helper

fun String.parsePaywall(): PaywallData? {
    if (isBlank()) return null
    return Json {
        ignoreUnknownKeys = true
        explicitNulls = false
    }.decodeFromString(this)
}

Sources for the JSON

  • Firebase Remote Config
  • Backend API
  • Local file

Repository Usage

override fun getPaywallConfig(placementType: String): PaywallData? {
    return when (placementType) {
        PAYWALL_HOME_ICON -> homeIconJson.parsePaywall()
        PAYWALL_SETTING   -> settingJson.parsePaywall()
        else -> null
    }
}

override fun getRetentionPlacement(current: String): String? {
    return when (current) {
        PAYWALL_HOME_ICON -> PAYWALL_HOME_ICON_SALE
        else -> null
    }
}
paywallRepository.initialize(isDebugMode = true)

if (paywallRepository.shouldShow(PAYWALL_HOME_ICON)) {
    showPaywall()
}

val data = paywallRepository.getPaywallData(PAYWALL_HOME_ICON)
val packages = paywallRepository.getPackages(data)

Full‑Screen Paywall Fragment (example)

class PaywallFull01Fragment :
    BasePaywallFragment(),
    PackageSubCallback {

    override val bindingInflater = FragmentPaywallFull01Binding::inflate

    private val adapter by lazy { PackageSubAdapter(this) }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        binding.rcvPackage.apply {
            layoutManager = GridLayoutManager(context, 3)
            adapter = this@PaywallFull01Fragment.adapter
        }

        binding.btnContinue.setOnClickListener {
            adapter.getPaywallProduct()?.let { buy(it.product) }
        }

        binding.imgClose.setOnClickListener { close() }
        binding.txtPolicy.setOnClickListener { openPolicy() }
        binding.txtTerm.setOnClickListener { openTerm() }
    }

    override fun bindData(config: PaywallData, packages: List) {
        adapter.submitData(packages)
    }

    override fun onSelectPackage(paywallProduct: PaywallProduct) {
        binding.btnContinue.setText(
            PaywallCtaUtils.getCtaStringResId(paywallProduct)
        )

        val descRes = PaywallDescUtils.getDescResIds(
            paywallProduct,
            PaywallDescUtils.LayoutType.FULL_SCREEN
        )

        if (descRes.descRes != 0) {
            binding.txtDescription.text = PaywallDescUtils.formatDescription(
                requireContext(),
                descRes.descRes,
                paywallProduct
            )
        }
    }
}

Single‑Package Paywall Dialog (example)

class PaywallDialog01 :
    BasePaywallDialog() {

    override val bindingInflater = DialogPaywall01Binding::inflate
    private var currentItem: PaywallProduct? = null

    override fun bindData(config: PaywallData, packages: List) {
        val item = packages.firstOrNull() ?: return
        currentItem = item

        binding.txtPrice.text = item.product.priceText
        binding.btnContinue.setText(
            PaywallCtaUtils.getCtaStringResId(item)
        )

        val descRes = PaywallDescUtils.getDescResIds(
            item,
            PaywallDescUtils.LayoutType.DIALOG_1_ITEM
        )

        if (descRes.descRes != 0) {
            binding.txtDescription.text = PaywallDescUtils.formatDescription(
                requireContext(),
                descRes.descRes,
                item
            )
        }

        binding.btnContinue.setOnClickListener { currentItem?.let { buy(it) } }
        binding.imgClose.setOnClickListener { close() }
    }
}

Use Cases – onboarding, hard paywall, feature lock, retention, sale‑off, exit‑intent.

CTA Logic

PaywallCtaUtils.getCtaStringResId(paywallProduct)
  • CTA varies by Trial / Sale / Lifetime.
  • If packageIdOrigin != null → sale mode is enabled.
  • Discount % is calculated from the original price.
  • Original price is shown with a strikethrough.

Purchase flow (handled by the SDK):

  1. Billing request
  2. Verification
  3. Emit purchaseStatus
Paywall A  → close
Sale Paywall Dialog → close
→ No further paywall

Quick Checklist

  • Valid JSON config
  • Placement → config mapping
  • Repository initialisation
UI TypeTypical PackagesNotes
Full‑screen2‑3 packagesGood for discovery
Dialog (sale)1 packageIdeal for exit‑intent or limited‑time offers
WeeklyLow entry priceEntry‑level hook
YearlyBest valueEncourages long‑term commitment
LifetimeAnchor priceHigh‑value upsell

Subscription Flow Overview

  • [ ] Test sandbox purchase
  • [ ] Test sale / retention

Soft Paywall (user‑initiated purchase)

  • Light upsell – does not interrupt the user experience.
  • Allows management of the subscription after purchase.

Kotlin Implementation

private val paywallRepository: PaywallRepository by inject()
private val launcher: PaywallLauncher by inject()

// Show the Subscribe CTA only when the paywall is enabled
ctlSub.isVisible =
    paywallRepository
        .getPaywallData(Constants.PaywallConfigKey.PAYWALL_SETTING)
        ?.enable == true

// Text description depends on purchase state
txtSubDes.text = if (paywallRepository.isPurchased()) {
    getString(R.string.unlocked_all_features)
} else {
    getString(R.string.upgrade_to_unlock_all)
}

// Show “Manage subscription” only for non‑lifetime purchases
ctlManageSubscription.isVisible =
    paywallRepository.isPurchased() &&
    paywallRepository.getCurrentPackagePurchased()?.type != PandaPackageType.LIFETIME

// 📌 Lifetime subscriptions do **not** need a “manage” button → matches Play Store UX
ctlSub.setOnClickListener {
    lifecycleScope.launch {
        val activity = activity ?: return@launch

        when (
            launcher.launch(
                activity,
                Constants.PaywallConfigKey.PAYWALL_SETTING
            )
        ) {
            PaywallResult.Success -> {
                // Update UI after a successful purchase
                ctlManageSubscription.isVisible =
                    paywallRepository.isPurchased() &&
                    paywallRepository.getCurrentPackagePurchased()?.type != PandaPackageType.LIFETIME

                txtSubDes.text = getString(R.string.unlocked_all_features)

                // Disable all ad formats
                NativeAdManager.disableAllAds()
                InterManager.disableAllAds()
                RewardAdsManager.disableAllAds()
                BannerAdManager.disableAllAds()
                PandaResumeAd.INSTANCE?.disableAllAds()
            }

            PaywallResult.Dismissed -> {
                // User closed the paywall
            }

            PaywallResult.NotShown -> {
                // Already purchased or not eligible to show the paywall
            }
        }
    }
}

UI Flow Diagram

Setting Screen

Subscribe CTA
   ↓ click
PaywallLauncher

┌───────────────┬───────────────┬───────────────┐
│   Success     │   Dismissed   │   NotShown    │
│ Disable ads   │ Log event     │ Continue app  │
│ Update UI     │               │               │
└───────────────┴───────────────┴───────────────┘

Checklist

  • ✅ Soft paywall – no forced purchase.
  • ✅ Paywall is hidden when enable = false.
  • ✅ Lifetime subscriptions “manage subscription”.
  • ✅ Ads are disabled only on Success.
  • ✅ UI updates instantly – no screen reload required.
Back to Blog

Related posts

Read more »

Happy New Year, community!

Introduction Hello everyone who comes across this blog, and Happy New Year! Project Overview The final goal of this project is an application with a purely dec...