📘 Paywall SDK – Tài liệu sử dụng TỪ A Z (kèm JSON mẫu)
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
| Field | Meaning |
|---|---|
| uiType | UI mapping (e.g., paywall_full_01, paywall_dialog_01) |
| ctaId | Default CTA when no package is selected |
| enable | Turn the paywall on/off |
| timesOpen | Number of app launches before showing (0 = always) |
| delayButtonX | Delay (seconds) before the close button appears |
| timesFree | Comma‑separated list of launches that are free |
| showAdsClose | Allow closing the paywall via an ad |
Package‑level fields
| Field | Meaning |
|---|---|
| packageId | SKU that is sold / active |
| packageIdOrigin | Original SKU (used to calculate discount) |
| packageType | weekly / monthly / yearly / lifetime |
| ctaId | CTA specific to this package |
| isPopular | Show “Popular” badge |
| isSelected | Auto‑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):
- Billing request
- Verification
- Emit
purchaseStatus
Paywall A → close
Sale Paywall Dialog → close
→ No further paywall
Quick Checklist
- Valid JSON config
- Placement → config mapping
- Repository initialisation
Recommended UI Patterns
| UI Type | Typical Packages | Notes |
|---|---|---|
| Full‑screen | 2‑3 packages | Good for discovery |
| Dialog (sale) | 1 package | Ideal for exit‑intent or limited‑time offers |
| Weekly | Low entry price | Entry‑level hook |
| Yearly | Best value | Encourages long‑term commitment |
| Lifetime | Anchor price | High‑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.