-
Notifications
You must be signed in to change notification settings - Fork 4
Subscriptions
AdManageKit provides comprehensive subscription management including state tracking, upgrade/downgrade support, and lifecycle management.
val products = listOf(
// Subscription with free trial offer
PurchaseItem("premium_monthly", "free_trial_7d", TYPE_IAP.SUBSCRIPTION),
// Subscription without trial
PurchaseItem("premium_yearly", null, TYPE_IAP.SUBSCRIPTION),
// Multiple tiers
PurchaseItem("basic_monthly", null, TYPE_IAP.SUBSCRIPTION),
PurchaseItem("pro_monthly", "intro_offer", TYPE_IAP.SUBSCRIPTION)
)AppPurchase.getInstance().initBilling(application, products)// Start subscription flow
AppPurchase.getInstance().subscribe(activity, "premium_monthly")enum class SubscriptionState {
ACTIVE, // Subscribed and will renew
CANCELLED, // Cancelled but still has access
GRACE_PERIOD, // Payment issue, still has access (server-side only)
ON_HOLD, // Payment declined, no access (client-side since v4.4.0)
PAUSED, // User paused (server-side only)
EXPIRED, // Subscription ended
NOT_SUBSCRIPTION // Product is not a subscription
}| State | Detectable Client-Side | How |
|---|---|---|
| ACTIVE | Yes | isAutoRenewing = true |
| CANCELLED | Yes |
isAutoRenewing = false, still in purchase list |
| GRACE_PERIOD | No | Requires server API |
| ON_HOLD | Yes (v4.4.0+) |
isSuspended = true (Play Billing 9) |
| PAUSED | No | Requires server API |
| EXPIRED | Yes | Not returned by queryPurchasesAsync
|
When Google Play cannot charge a subscriber's payment method, the subscription enters account hold. The purchase record still exists, but the user has not paid and must not keep premium access.
Since v4.4.0 this is detected client-side; earlier versions needed a server round-trip through the Play Developer API.
val billing = AppPurchase.getInstance()
billing.hasSubscriptionOnHold() // any subscription on hold
purchaseResult.isSuspended // this specific subscription
purchaseResult.subscriptionState // SubscriptionState.ON_HOLDBehavior change in 4.4.0.
isSubscriptionActive()now returnsfalsefor an on-hold subscription, andgetSubscriptionState()returnsON_HOLDrather thanACTIVE/CANCELLED. This matches Google's requirement — an on-hold user's payment was declined. If your app deliberately keeps serving these users, checkisSuspended()explicitly rather than relying on the old behavior.
Play can show the user an in-app flow to fix their payment method. Google recommends calling this on foreground entry for apps that sell subscriptions:
billing.showInAppMessages(activity, object : InAppMessageListener {
override fun onSubscriptionRecovered(purchaseToken: String) {
// AppPurchase has already refreshed — isPurchased() is correct here.
refreshPremiumUi()
}
override fun onNoActionNeeded() { }
})A subscription upgrade or downgrade the user started but has not paid for yet:
billing.hasPendingSubscriptionChange()
purchaseResult.hasPendingPurchaseUpdate()
purchaseResult.pendingProductIds
purchaseResult.pendingPurchaseTokenThe current plan remains the entitlement in force until the change completes, so show "plan change pending" rather than switching the UI to the new tier.
// Any subscription active?
if (AppPurchase.getInstance().isSubscribed()) {
showPremiumContent()
}
// Specific subscription active?
if (AppPurchase.getInstance().isSubscribed("premium_yearly")) {
showYearlyBenefits()
}val state = AppPurchase.getInstance().getSubscriptionState("premium_monthly")
when (state) {
SubscriptionState.ACTIVE -> {
// User is subscribed and will renew
showPremiumUI()
}
SubscriptionState.CANCELLED -> {
// User cancelled but still has access until expiration
showRenewalPrompt()
}
SubscriptionState.EXPIRED -> {
// Subscription ended
showSubscribeButton()
}
else -> {
// Not subscribed or other state
}
}// Check if any subscription is cancelled (but still active)
if (AppPurchase.getInstance().hasSubscriptionCancelled()) {
showResubscribePrompt()
}
// Check if all subscriptions will renew
if (AppPurchase.getInstance().willSubscriptionsRenew()) {
// User is committed subscriber
}
// Get all active subscriptions
val subscriptions = AppPurchase.getInstance().getActiveSubscriptions()
for (sub in subscriptions) {
println("${sub.getFirstProductId()}: ${sub.getSubscriptionStateString()}")
}val subscription = AppPurchase.getInstance().getSubscription("premium_monthly")
if (subscription != null) {
// Basic info
val orderId = subscription.orderId
val purchaseTime = subscription.getPurchaseTimeFormatted()
// State info
val isActive = subscription.isSubscriptionActive()
val willRenew = subscription.willSubscriptionRenew()
val isCancelled = subscription.isSubscriptionCancelled()
val stateString = subscription.getSubscriptionStateString()
// Display
showSubscriptionInfo(
status = stateString, // "Active", "Cancelled (access until expiration)"
since = purchaseTime, // "2024-01-15 14:30:00"
willRenew = willRenew // true/false
)
}// Get formatted price
val monthlyPrice = AppPurchase.getInstance().getPriceSub("premium_monthly")
// "$9.99"
val yearlyPrice = AppPurchase.getInstance().getPriceSub("premium_yearly")
// "$99.99"Specify trial offer ID when creating PurchaseItem:
// The second parameter is the offer ID from Google Play Console
PurchaseItem("premium_monthly", "free_trial_7d", TYPE_IAP.SUBSCRIPTION)The library automatically selects the matching offer when subscribing.
Use OfferInfo to inspect each offer's pricing phases for the paywall UI:
val billing = AppPurchase.getInstance()
billing.getTrialOffer("premium_monthly")?.let { trial ->
badge.text = "Free for ${trial.trialPeriod}" // "P7D"
afterTrial.text = "${trial.basePrice}/${trial.billingPeriod}"
}
billing.getBaseOffer("premium_monthly")?.let { base ->
price.text = base.basePrice // "$9.99"
}
// Iterate every offer (trial, intro, base, etc.)
for (offer in billing.getOffers("premium_monthly")) {
// offer.isFreeTrial, offer.hasIntroPrice, offer.introPrice, offer.offerTags ...
}Since v4.4.0 you can also buy a specific offer rather than letting the library pick one, and compare plans across billing cadences:
billing.subscribe(activity, offer) // buy exactly this offer
billing.isEligibleForFreeTrial("premium_monthly") // Play filters per account
billing.getSavingsPercent("premium_monthly", "premium_yearly") // 50 → "Save 50%"
billing.getFormattedPricePerMonth("premium_yearly") // "$5.00"See Subscription Offers for the full offer API, and
API Reference for the OfferInfo schema.
See Subscription Upgrades for upgrade/downgrade handling.
fun updateSubscriptionUI() {
val state = AppPurchase.getInstance().getSubscriptionState("premium_monthly")
subscriptionBadge.text = when (state) {
SubscriptionState.ACTIVE -> "Premium Member"
SubscriptionState.CANCELLED -> "Premium (Expires Soon)"
else -> "Free User"
}
}if (AppPurchase.getInstance().hasSubscriptionCancelled()) {
showDialog(
title = "Your subscription is ending",
message = "Renew now to keep premium features",
action = "Renew" to { reopenSubscription() }
)
}
fun reopenSubscription() {
// This will show the resubscribe flow in Google Play
AppPurchase.getInstance().subscribe(activity, "premium_monthly")
}override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Verify purchases to restore subscription state
AppPurchase.getInstance().verifyPurchased(true)
}For grace period detection, implement server-side verification using Google Play Developer API and Real-time Developer Notifications (RTDN).
The Google Play Billing Library does NOT provide subscription expiry dates client-side. To get accurate expiry dates, you need server-side verification.
// In Application.onCreate() or early initialization
AppPurchase.getInstance().setSubscriptionVerificationCallback { packageName, subscriptionId, purchaseToken, listener ->
// Call your backend API
yourBackendApi.verifySubscription(packageName, subscriptionId, purchaseToken,
onSuccess = { expiryTimeMillis, isAutoRenewing ->
val details = SubscriptionVerificationCallback.SubscriptionDetails.Builder()
.setExpiryTimeMillis(expiryTimeMillis)
.setAutoRenewing(isAutoRenewing)
.build()
listener.onVerified(details)
},
onError = { error ->
listener.onVerificationFailed(error)
}
)
}AppPurchase.getInstance().verifySubscription("premium_monthly",
object : AppPurchase.SubscriptionVerificationListener {
override fun onVerified(subscription: PurchaseResult) {
// Expiry info now available
val expiryDate = subscription.getExpiryTimeFormatted("dd MMM yyyy")
val remainingDays = subscription.getRemainingDays()
val isExpired = subscription.isExpired()
updateUI(expiryDate, remainingDays)
}
override fun onVerificationFailed(errorMessage: String?) {
Log.e("Subscription", "Verification failed: $errorMessage")
}
}
)
// Verify all active subscriptions at once
AppPurchase.getInstance().verifyAllSubscriptions(listener)After verification, expiry data is stored and accessible:
// Get expiry time in milliseconds
val expiryMillis = AppPurchase.getInstance().getSubscriptionExpiryTime("premium_monthly")
// Get formatted expiry date
val expiryDate = AppPurchase.getInstance().getSubscriptionExpiryTimeFormatted("premium_monthly")
// "2024-03-15 14:30:00"
// Custom format
val customFormat = AppPurchase.getInstance().getSubscriptionExpiryTimeFormatted("premium_monthly", "dd MMM yyyy")
// "15 Mar 2024"
// Get remaining days
val daysLeft = AppPurchase.getInstance().getSubscriptionRemainingDays("premium_monthly")
// 30
// Check if expired
val isExpired = AppPurchase.getInstance().isSubscriptionExpired("premium_monthly")
// falseval subscription = AppPurchase.getInstance().getSubscription("premium_monthly")
if (subscription != null && subscription.isExpiryVerified()) {
// Expiry data available
val expiryDate = subscription.getExpiryDate() // Date object
val expiryFormatted = subscription.getExpiryTimeFormatted() // String
val remainingDays = subscription.getRemainingDays() // Int (-1 if not verified)
val remainingMillis = subscription.getRemainingTime() // Long (-1 if not verified)
val isExpired = subscription.isExpired() // Boolean
}Your backend should call Google Play Developer API:
GET https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/subscriptionsv2/tokens/{purchaseToken}
Response includes:
-
expiryTime- RFC 3339 timestamp -
subscriptionState- ACTIVE, CANCELED, IN_GRACE_PERIOD, ON_HOLD, PAUSED, EXPIRED -
linkedPurchaseToken- For upgrade/downgrade tracking
fun updateSubscriptionUI() {
val subscription = AppPurchase.getInstance().getSubscription("premium_monthly")
if (subscription == null) {
showNotSubscribedUI()
return
}
if (!subscription.isExpiryVerified()) {
// Verify first
AppPurchase.getInstance().verifySubscription("premium_monthly",
object : AppPurchase.SubscriptionVerificationListener {
override fun onVerified(sub: PurchaseResult) {
showSubscriptionDetails(sub)
}
override fun onVerificationFailed(error: String?) {
showBasicSubscriptionUI(subscription)
}
}
)
} else {
showSubscriptionDetails(subscription)
}
}
fun showSubscriptionDetails(subscription: PurchaseResult) {
val daysLeft = subscription.getRemainingDays()
when {
subscription.isExpired() -> {
statusText.text = "Subscription Expired"
renewButton.visibility = View.VISIBLE
}
daysLeft <= 7 && !subscription.isAutoRenewing -> {
statusText.text = "Expires in $daysLeft days"
renewPrompt.visibility = View.VISIBLE
}
else -> {
statusText.text = "Premium until ${subscription.getExpiryTimeFormatted("dd MMM yyyy")}"
}
}
}AdManageKit v3.3.4 | GitHub | API Docs | Report Issue | Buy me a coffee