DEV Community

krlz
krlz

Posted on

Android SaaS App with Subscriptions: Complete 2025 Guide

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_PENDING before activation
  • ReplacementMode API - Replaces deprecated ProrationMode for 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)
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
    }
)
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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'
}
Enter fullscreen mode Exit fullscreen mode

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
            }
    }
}
Enter fullscreen mode Exit fullscreen mode

5. Purchase Token Storage Best Practices

Critical Guidelines

  1. Always store tokens server-side - Never rely solely on local storage
  2. Keep ALL purchase tokens - Required for restore purchases functionality
  3. Handle linkedPurchaseToken - Invalidate old tokens when upgrades occur
  4. Store product type flag - Differentiate between subscriptions and one-time purchases
  5. Use order_id for refund lookups - More reliable than purchase_token
  6. 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()
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

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
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

  1. Not verifying purchases server-side - Always validate with Google's API
  2. Ignoring linkedPurchaseToken - Causes duplicate subscriptions
  3. Poor offline handling - Cache entitlements locally with expiry
  4. Missing RTDN integration - Subscription states will be stale
  5. Not handling pending transactions - PBL 7 introduces pending states for prepaid
  6. Hardcoding prices - Always fetch dynamically from Play Billing
  7. Ignoring grace period - Users expect continued access during payment issues
  8. Not testing all states - Use license testers extensively
  9. Single token storage - Store ALL tokens for restore functionality
  10. Missing August 2025 deadline - Update to PBL 7+ before deadline

Resources


Sources


Last updated: December 2025

Top comments (0)