Android SaaS App with Subscriptions: Complete 2025 Guide
Building a SaaS (Software as a Service) application on Android with subscription-based monetization requires integrating billing systems, managing user authentication, and implementing backend infrastructure. This comprehensive guide covers the latest approaches and best practices for 2025.
Market Context 2025
The global SaaS market size is estimated at USD 408.21 billion in 2025, projected to reach USD 1,251.35 billion by 2034. The subscription billing management market is expected to reach $17.95 billion by 2030, growing at a CAGR of 16.9%.
1. Google Play Billing Library
Current Version: Play Billing Library 7.x (Required by August 2025)
Important Deadline: By August 31, 2025, all new apps and updates must use Billing Library version 7 or newer. Extensions available until November 1, 2025.
Key Features in Version 7.0+
- Installment Subscriptions - Users pay in smaller, manageable installments (available in Brazil, France, Italy, Spain)
-
Pending Transactions for Subscriptions - Handle
SUBSCRIPTION_STATE_PENDINGbefore activation -
ReplacementMode API - Replaces deprecated
ProrationModefor upgrades/downgrades -
User Choice Billing -
enableUserChoiceBilling()for alternative payment options
Implementation
// build.gradle.kts
implementation("com.android.billingclient:billing-ktx:7.1.1")
// Initialize BillingClient
val billingClient = BillingClient.newBuilder(context)
.setListener { billingResult, purchases ->
// Handle purchase updates
purchases?.forEach { purchase ->
if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
// Verify on backend, then acknowledge
verifyAndAcknowledge(purchase)
}
}
}
.enablePendingPurchases(
PendingPurchasesParams.newBuilder()
.enablePrepaidPlans()
.build()
)
.build()
// Connect to Google Play
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(billingResult: BillingResult) {
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
querySubscriptions()
}
}
override fun onBillingServiceDisconnected() {
// Implement retry logic
}
})
// Query available subscriptions
fun querySubscriptions() {
val params = QueryProductDetailsParams.newBuilder()
.setProductList(
listOf(
QueryProductDetailsParams.Product.newBuilder()
.setProductId("premium_monthly")
.setProductType(BillingClient.ProductType.SUBS)
.build(),
QueryProductDetailsParams.Product.newBuilder()
.setProductId("premium_yearly")
.setProductType(BillingClient.ProductType.SUBS)
.build()
)
)
.build()
billingClient.queryProductDetailsAsync(params) { billingResult, productDetailsList ->
// Check for installment plans (PBL 7+)
productDetailsList.forEach { product ->
product.subscriptionOfferDetails?.forEach { offer ->
offer.installmentPlanDetails?.let { installment ->
// Handle installment subscription display
val commitments = installment.installmentPlanCommitment
}
}
}
}
}
// Launch purchase flow
fun launchPurchase(activity: Activity, productDetails: ProductDetails, offerToken: String) {
val flowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(
listOf(
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(productDetails)
.setOfferToken(offerToken)
.build()
)
)
.build()
billingClient.launchBillingFlow(activity, flowParams)
}
Google Play Commission Rates 2025
| Scenario | Rate |
|---|---|
| First $1M annual revenue | 15% |
| Subscriptions after first year | 15% |
| First-year subscriptions (standard) | 30% |
2. 2025 Policy Update: Alternative Billing in the US
Major Change (October 2025)
Following the Epic Games ruling, Google now allows US developers to offer alternative payment methods:
- No requirement to use Google Play Billing exclusively
- Can communicate with users about alternative payment options
- No price restrictions based on payment method used
- Developers can process payments through Stripe, PayPal, or custom solutions
External Offers Program (EEA)
For European Economic Area, developers can lead users outside the app to promote offers, subject to Google Play's Payments policy.
// User Choice Billing implementation
val billingClient = BillingClient.newBuilder(context)
.enableUserChoiceBilling(UserChoiceBillingListener { userChoiceDetails ->
// User selected alternative billing
val externalTransactionToken = userChoiceDetails.externalTransactionToken
// Process with your payment provider
processAlternativePayment(externalTransactionToken)
})
.build()
3. Third-Party Subscription Platforms
RevenueCat
The most popular cross-platform subscription management SDK.
// Setup
Purchases.configure(
PurchasesConfiguration.Builder(context, "your_api_key")
.appUserID(userId)
.build()
)
// Fetch offerings
Purchases.sharedInstance.getOfferingsWith(
onError = { error -> /* Handle error */ },
onSuccess = { offerings ->
offerings.current?.availablePackages?.let { packages ->
// Display subscription options
}
}
)
// Make purchase
Purchases.sharedInstance.purchase(
PurchaseParams.Builder(activity, packageToPurchase).build(),
onError = { error, userCancelled -> },
onSuccess = { storeTransaction, customerInfo ->
if (customerInfo.entitlements["premium"]?.isActive == true) {
// Unlock premium features
}
}
)
// Check subscription status
Purchases.sharedInstance.getCustomerInfo(
onError = { /* Handle */ },
onSuccess = { customerInfo ->
val isPremium = customerInfo.entitlements["premium"]?.isActive == true
}
)
Pricing: Free up to $2,500 MTR, then 1% of MTR
Adapty
Strong alternative with superior A/B testing capabilities.
Advantages over RevenueCat:
- More sophisticated multivariate paywall testing
- More analytics metrics on free plan
- Real-time dashboard data
- Better customer support response times
- No-code paywall builder with granular control
Pricing: Starts at $99/month
Comparison Table
| Feature | RevenueCat | Adapty |
|---|---|---|
| SDK Platforms | iOS, Android, Flutter, RN, Unity, Cordova, Ionic | iOS, Android, Flutter, RN, Unity |
| Free Tier Analytics | 6 metrics | 10+ metrics |
| Real-time Data | No | Yes |
| A/B Testing | Basic | Advanced multivariate |
| Paywall Builder | Native (Paywalls v2) | No-code drag-drop |
| Starting Price | Free/$8/mo | $99/mo |
4. Backend Architecture with Supabase
Supabase provides an excellent open-source backend for SaaS apps with PostgreSQL, authentication, and real-time capabilities.
Database Schema for Subscriptions
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Users table (extends Supabase auth.users)
CREATE TABLE public.profiles (
id UUID REFERENCES auth.users(id) PRIMARY KEY,
email TEXT UNIQUE,
full_name TEXT,
avatar_url TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Subscription plans table
CREATE TABLE public.subscription_plans (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
price_monthly DECIMAL(10,2),
price_yearly DECIMAL(10,2),
features JSONB DEFAULT '[]'::jsonb,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Subscriptions table (stores purchase tokens)
CREATE TABLE public.subscriptions (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
user_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE,
plan_id UUID REFERENCES public.subscription_plans(id),
-- Platform info
platform TEXT NOT NULL CHECK (platform IN ('google_play', 'stripe', 'app_store')),
-- Google Play specific fields
purchase_token TEXT,
order_id TEXT,
product_id TEXT,
linked_purchase_token TEXT, -- Important for handling upgrades/downgrades
-- Stripe specific fields
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
-- Status tracking
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'active', 'cancelled', 'expired', 'paused', 'grace_period', 'on_hold')),
-- Dates
starts_at TIMESTAMP WITH TIME ZONE,
expires_at TIMESTAMP WITH TIME ZONE,
cancelled_at TIMESTAMP WITH TIME ZONE,
-- Billing
auto_renew BOOLEAN DEFAULT true,
billing_period TEXT CHECK (billing_period IN ('monthly', 'yearly', 'weekly')),
-- Metadata
raw_data JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Indexes
UNIQUE(purchase_token),
UNIQUE(stripe_subscription_id)
);
-- Entitlements table (what features user can access)
CREATE TABLE public.entitlements (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
user_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE,
subscription_id UUID REFERENCES public.subscriptions(id) ON DELETE SET NULL,
feature_key TEXT NOT NULL,
is_active BOOLEAN DEFAULT true,
granted_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE,
UNIQUE(user_id, feature_key)
);
-- Enable Row Level Security
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.subscriptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.entitlements ENABLE ROW LEVEL SECURITY;
-- RLS Policies
CREATE POLICY "Users can view own profile"
ON public.profiles FOR SELECT
USING (auth.uid() = id);
CREATE POLICY "Users can view own subscriptions"
ON public.subscriptions FOR SELECT
USING (auth.uid() = user_id);
-- Function to check if user has active subscription
CREATE OR REPLACE FUNCTION public.has_active_subscription(check_user_id UUID)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM public.subscriptions
WHERE user_id = check_user_id
AND status = 'active'
AND (expires_at IS NULL OR expires_at > NOW())
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
Supabase Edge Function for Webhook Handling
// supabase/functions/payment-webhook/index.ts
import { serve } from "https://deno.land/std@0.168.0/http/server.ts"
import { createClient } from "https://esm.sh/@supabase/supabase-js@2"
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
serve(async (req) => {
const payload = await req.json()
// Handle Google Play RTDN
if (payload.subscriptionNotification) {
const notification = payload.subscriptionNotification
const purchaseToken = notification.purchaseToken
// Verify with Google Play Developer API
const subscriptionData = await verifyGooglePlayPurchase(
notification.subscriptionId,
purchaseToken
)
// Update subscription in database
const { error } = await supabase
.from('subscriptions')
.upsert({
purchase_token: purchaseToken,
product_id: notification.subscriptionId,
status: mapGooglePlayState(subscriptionData.subscriptionState),
expires_at: new Date(parseInt(subscriptionData.expiryTimeMillis)),
auto_renew: subscriptionData.autoRenewing,
linked_purchase_token: subscriptionData.linkedPurchaseToken,
raw_data: subscriptionData,
updated_at: new Date().toISOString()
}, {
onConflict: 'purchase_token'
})
// Invalidate linked purchase token if exists
if (subscriptionData.linkedPurchaseToken) {
await supabase
.from('subscriptions')
.update({ status: 'replaced' })
.eq('purchase_token', subscriptionData.linkedPurchaseToken)
}
}
// Handle Stripe webhooks
if (payload.type?.startsWith('customer.subscription')) {
const subscription = payload.data.object
await supabase
.from('subscriptions')
.upsert({
stripe_subscription_id: subscription.id,
stripe_customer_id: subscription.customer,
status: mapStripeStatus(subscription.status),
expires_at: new Date(subscription.current_period_end * 1000),
auto_renew: !subscription.cancel_at_period_end,
raw_data: subscription,
updated_at: new Date().toISOString()
}, {
onConflict: 'stripe_subscription_id'
})
}
return new Response(JSON.stringify({ received: true }), {
headers: { "Content-Type": "application/json" }
})
})
function mapGooglePlayState(state: string): string {
const stateMap: Record<string, string> = {
'SUBSCRIPTION_STATE_PENDING': 'pending',
'SUBSCRIPTION_STATE_ACTIVE': 'active',
'SUBSCRIPTION_STATE_PAUSED': 'paused',
'SUBSCRIPTION_STATE_IN_GRACE_PERIOD': 'grace_period',
'SUBSCRIPTION_STATE_ON_HOLD': 'on_hold',
'SUBSCRIPTION_STATE_CANCELED': 'cancelled',
'SUBSCRIPTION_STATE_EXPIRED': 'expired'
}
return stateMap[state] || 'unknown'
}
Android Client Integration with Supabase
// SupabaseSubscriptionManager.kt
class SupabaseSubscriptionManager(
private val supabase: SupabaseClient
) {
suspend fun syncPurchaseToBackend(purchase: Purchase): Result<Unit> {
return try {
supabase.from("subscriptions")
.upsert(
mapOf(
"user_id" to supabase.auth.currentUserOrNull()?.id,
"purchase_token" to purchase.purchaseToken,
"order_id" to purchase.orderId,
"product_id" to purchase.products.firstOrNull(),
"platform" to "google_play",
"status" to "pending_verification",
"raw_data" to purchase.originalJson
)
)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun checkEntitlement(featureKey: String): Boolean {
val userId = supabase.auth.currentUserOrNull()?.id ?: return false
val result = supabase.from("entitlements")
.select()
.eq("user_id", userId)
.eq("feature_key", featureKey)
.eq("is_active", true)
.maybeSingle()
.decodeAsOrNull<Entitlement>()
return result != null && (result.expiresAt == null || result.expiresAt > Clock.System.now())
}
fun observeSubscriptionStatus(): Flow<SubscriptionStatus> {
val userId = supabase.auth.currentUserOrNull()?.id ?: return flowOf(SubscriptionStatus.None)
return supabase.from("subscriptions")
.selectAsFlow(
primaryKey = Subscription::id
) {
eq("user_id", userId)
}
.map { subscriptions ->
subscriptions.firstOrNull { it.status == "active" }
?.let { SubscriptionStatus.Active(it) }
?: SubscriptionStatus.None
}
}
}
5. Purchase Token Storage Best Practices
Critical Guidelines
- Always store tokens server-side - Never rely solely on local storage
- Keep ALL purchase tokens - Required for restore purchases functionality
- Handle linkedPurchaseToken - Invalidate old tokens when upgrades occur
- Store product type flag - Differentiate between subscriptions and one-time purchases
- Use order_id for refund lookups - More reliable than purchase_token
- Implement RTDN (Real-Time Developer Notifications) - Essential for state sync
// Token verification flow
suspend fun verifyAndStorePurchase(purchase: Purchase) {
// 1. Send to backend for verification
val verificationResult = api.verifyPurchase(
purchaseToken = purchase.purchaseToken,
productId = purchase.products.first(),
orderId = purchase.orderId
)
// 2. Backend verifies with Google Play Developer API
// 3. Backend stores token and updates subscription status
// 4. Backend handles linkedPurchaseToken invalidation
// 5. Acknowledge purchase only after backend confirmation
if (verificationResult.isValid) {
billingClient.acknowledgePurchase(
AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken)
.build()
)
}
}
6. Stripe Integration (Post October 2025)
With alternative billing now allowed in the US, Stripe becomes viable for Android in-app purchases.
// Stripe Android SDK integration
implementation("com.stripe:stripe-android:20.+")
// Initialize Stripe
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
PaymentConfiguration.init(
applicationContext,
"pk_live_your_publishable_key"
)
}
}
// Create subscription with Payment Sheet
class SubscriptionActivity : AppCompatActivity() {
private lateinit var paymentSheet: PaymentSheet
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
paymentSheet = PaymentSheet(this, ::onPaymentSheetResult)
// Fetch payment intent from your backend
fetchPaymentIntent()
}
private fun presentPaymentSheet(
paymentIntentClientSecret: String,
customerConfig: PaymentSheet.CustomerConfiguration
) {
paymentSheet.presentWithPaymentIntent(
paymentIntentClientSecret,
PaymentSheet.Configuration(
merchantDisplayName = "Your App",
customer = customerConfig,
allowsDelayedPaymentMethods = true
)
)
}
private fun onPaymentSheetResult(result: PaymentSheetResult) {
when (result) {
is PaymentSheetResult.Completed -> {
// Subscription created successfully
}
is PaymentSheetResult.Canceled -> {
// User canceled
}
is PaymentSheetResult.Failed -> {
// Handle error
}
}
}
}
7. Recommended Architecture 2025
For Startups / MVPs
Stack:
- Google Play Billing Library 7.x via RevenueCat
- Supabase (Auth + PostgreSQL + Edge Functions)
- Kotlin + Jetpack Compose
- Cost: ~$25/month (Supabase Pro)
For Scale-ups
Stack:
- Direct Play Billing + Stripe integration
- Node.js/NestJS or Go backend
- PostgreSQL + Redis
- Kubernetes on GCP/AWS
8. Testing Checklist
Test Scenarios
- [ ] New subscription purchase
- [ ] Subscription renewal
- [ ] Subscription cancellation
- [ ] Grace period handling
- [ ] Account hold recovery
- [ ] Upgrade/downgrade between plans
- [ ] Restore purchases on new device
- [ ] Installment subscription (if applicable)
- [ ] Pending transaction handling (prepaid)
- [ ] Network failure during purchase
- [ ] Backend webhook failure recovery
Google Play License Testing
// Add test accounts in Play Console
// Test purchases are free and have accelerated renewals:
// - Monthly: renews every 5 minutes
// - Yearly: renews every 30 minutes
// - Test subscriptions auto-cancel after 6 renewals
9. Key Metrics to Track
| Metric | Description |
|---|---|
| MRR | Monthly Recurring Revenue |
| ARR | Annual Recurring Revenue |
| Churn Rate | Monthly/annual subscription cancellations |
| LTV | Lifetime Value per customer |
| Trial Conversion | % of trials converting to paid |
| ARPU | Average Revenue Per User |
| Paywall Conversion | % of users converting at paywall |
10. Common Pitfalls to Avoid
- Not verifying purchases server-side - Always validate with Google's API
- Ignoring linkedPurchaseToken - Causes duplicate subscriptions
- Poor offline handling - Cache entitlements locally with expiry
- Missing RTDN integration - Subscription states will be stale
- Not handling pending transactions - PBL 7 introduces pending states for prepaid
- Hardcoding prices - Always fetch dynamically from Play Billing
- Ignoring grace period - Users expect continued access during payment issues
- Not testing all states - Use license testers extensively
- Single token storage - Store ALL tokens for restore functionality
- Missing August 2025 deadline - Update to PBL 7+ before deadline
Resources
- Google Play Billing Documentation
- Play Billing Library 7 Migration Guide
- RevenueCat Documentation
- Adapty Documentation
- Supabase Documentation
- Stripe Android SDK
- Supabase Stripe Quickstart
Sources
- Business of Apps - Subscription Management Software
- Google Play Billing Release Notes
- RevenueCat - Google Play Billing Library 7 Features
- Adapty - RevenueCat Alternatives
- 9to5Google - Alternative Billing in US
- Stripe - In-App Purchases Documentation
- Medium - Implementing linkedPurchaseToken
- Supabase - Stripe Webhooks
- DEV Community - Flutter + Supabase SaaS
Last updated: December 2025
Top comments (0)