SubSovereign
All guides

SubSovereign on Android — integration guide

This guide takes your Android app (phone, tablet, Android TV, or Amazon Fire TV) from "I have no idea who paid me" to "my app unlocks the right features for the right user, verified on my own server." It is written to be followed start to finish — no prior experience with subscription tooling assumed. Every Kotlin code sample in it is compiled against the SDK on every test run, so what you read here is what the code does.

What SubSovereign does for you

Your users subscribe through the Google Play store (or Amazon on Fire TV). SubSovereign answers one question for your app, reliably: what has this user actually paid for?

  • Your app asks SubSovereign for the user's entitlement — the access they've unlocked.
  • The receipt validation happens on the server, directly with the store, so a modified app can't fake a subscription.
  • The paywall — the screen that offers your plans — is configured remotely and drawn by the SDK, so you can change prices, trials, and wording without shipping a new app version.
  • The EU withdrawal function — the statutory cancel control — is drawn by the SDK too, in the customer's language, so a legal notice cannot drift into marketing.
  • It is self-hosted: SubSovereign runs on your infrastructure, your users' data stays with you, and there is no revenue share — you keep 100% of what your users pay.

You never trust the phone. The phone asks; the server decides.

Before you start

You'll need:

  1. A running SubSovereign server (your self-hosted deployment). You'll point the SDK at its URL. If you don't have one yet, deploy it first — the rest of this guide assumes it's live.
  2. An app registered in the dashboard. In the SubSovereign dashboard, create an app. You'll get two things:
    • an appId (identifies your app), and
    • an API key (the credential your app uses to talk to the server). Register it for the surface it ships on — phone, Android TV or Fire TV. One codebase shipping to more than one surface is fine; see Phones and televisions under Step 2.
  3. Access levels created. In the dashboard, define the access levels (also called tiers) your app grants — for example pro or premium — and link each to the store product IDs your users buy.
  4. Store billing already working. SubSovereign validates and tracks purchases; it does not replace Google Play Billing (or Amazon IAP). Set those up in your app as normal — SubSovereign sits just after the purchase to verify and record it.

On the code side you need Kotlin with coroutines (the SDK calls are suspend functions), Jetpack Compose (the paywall and the cancel control are composables), and minSdk 24 or higher.

Step 1 — Add the SDK

The SDK ships as source for now: there is nothing to download from Maven Central yet. Take the folder sdk-android/src/main/java from the SubSovereign repository into your project and compile it as part of your app — exactly as the sample app in the same repository does. Below is what the SDK itself needs, to add to what your app already has: the two plugins its code uses, and its own dependencies pinned to the versions it is built and tested with (the last line is what the sample uses to put a composable on screen). On AGP 8 also apply org.jetbrains.kotlin.android; on AGP 9 Kotlin is built in. Declare the plugin versions in your root build file, as the sample does.

plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.plugin.compose")
    id("org.jetbrains.kotlin.plugin.serialization")   // the SDK's models are @Serializable
}

android {
    // ... your existing settings; minSdk 24 or higher
    buildFeatures { compose = true }
    // Compile the SDK's source folder as part of your app — wherever you put it.
    sourceSets["main"].kotlin.srcDir("../subsovereign-sdk/src/main/java")
}

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
    implementation("io.coil-kt:coil-compose:2.7.0")     // hero images — the SDK's one UI dependency
    implementation(platform("androidx.compose:compose-bom:2024.09.02"))
    implementation("androidx.compose.foundation:foundation")
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.runtime:runtime")
    implementation("androidx.activity:activity-compose:1.9.3")   // setContent, as in the sample
}

Make sure your app has the internet permission in AndroidManifest.xml:

<uses-permission android:name="android.permission.INTERNET" />

Because the SDK is compiled as your own source, there is no version to bump: to update it, replace the folder. When a published artefact exists, this step becomes one dependency line and this guide will say so.

Step 2 — Configure once, when your app starts

Configure the SDK a single time — the best place is your Application.onCreate(), or right after the user signs in. You give it your API key, your app ID, a stable identifier for this user, their language, and which surface the app is running on.

import com.subsovereign.sdk.SubSovereign
import com.subsovereign.sdk.SubSovereignConfig
import com.subsovereign.sdk.detectPlatform

SubSovereign.configure(
    SubSovereignConfig(
        apiKey   = "YOUR_APP_API_KEY",                    // from the dashboard
        appId    = "your-app-id",                         // from the dashboard
        baseUrl  = "https://subs.yourdomain.com/api/v1",  // YOUR self-hosted server
        userId   = currentUser.id,                        // your own stable user id
        locale   = customerLanguage,                      // the CUSTOMER's language, e.g. "fr" — decides the words they get
        platform = detectPlatform(this),                  // "android", "androidtv" or "firetv"
    )
)

A few things worth knowing:

  • userId is yours. Use whatever stable ID you already have for a signed-in user. Use the same value every time so the user's access follows them across devices. If you support sign-out and a different user signs in, just call configure again with the new userId.
  • baseUrl points at your server. The default in the SDK is a placeholder — set this to your own SubSovereign deployment.
  • locale is the customer's language, not your console's, and it is decided HERE: the server returns the paywall and the words of the statutory notice in whatever you configure, and the SDK formats dates for it. Take it from the customer's profile or the device, not a literal.

Phones and televisions

One Play codebase can ship to phones, tablets and Android TV; a Fire TV build ships through Amazon. platform tells the SDK which surface it is on — "android", "androidtv" or "firetv" — and that decides three things: which paywall the server sends (a television paywall is laid out for a room, not a hand), the surface a cancellation is filed under, and where your customers are recorded as coming from. detectPlatform(context) answers it at runtime: a Fire TV by Amazon's own device feature, an Android TV by its configuration or Leanback, everything else as a phone. Don't hard-code "android" in a build that also runs on televisions — that fetches the phone paywall on the TV.

On Fire TV, one television paywall can serve both shops. A Fire TV build asks the server for the Fire TV paywall; if you have not published one, the server sends your Android TV paywall instead — the same screen, laid out for a room — and never the phone paywall. The one thing in that paywall the shop cares about is each plan's store product ID, so give a plan the same ID in Play and in Amazon and one TV paywall is buyable on both. (The paywall's callback hands you the plan's own id; read its storeProductId off the matching entry in config.products and hand THAT to Play Billing or Amazon's purchasing SDK — never the id.) If your IDs differ between the shops, publish a Fire TV paywall of its own with the Amazon IDs. A Fire TV build with neither is served an Error rather than a paywall it cannot sell. The SDK never guesses a surface: a platform value it does not recognise is an Error that names it, and nothing is sent.

Step 3 — Check what the user can access

The quick yes/no is hasAccess(). It fails closed: on a network blip, a server error, or an SDK that has not been configured it answers false, so a momentary outage can never unlock a paid feature. Use it at the point of gating a feature:

lifecycleScope.launch {
    if (SubSovereign.hasAccess("pro")) unlockProFeatures() else showPaywall()
}

hasAccess("pro") is true only when an active entitlement carries that access level name; hasAccess() with no name is the server's overall verdict.

When you need the detail — what exactly is active, when it renews, or to tell a "no" from a "could not check" — call checkEntitlements(). It returns a result you decide about, and the right decision on an error is usually to keep the customer where they were:

import com.subsovereign.sdk.SubSovereignResult
import kotlinx.coroutines.launch

lifecycleScope.launch {
    when (val result = SubSovereign.checkEntitlements()) {
        is SubSovereignResult.Success -> {
            val ent = result.data
            if (ent.hasAccess) unlockProFeatures() else showPaywall()
        }
        is SubSovereignResult.Error -> {
            // A network hiccup or a server error. Keep the customer on whatever access they
            // last had and try again later — never lock a paying customer out over a blip.
            Log.w("MyApp", "Entitlement check failed: ${result.message}")
        }
    }
}

If your app has more than one tier, look inside result.data.entitlements — each one tells you exactly which access level is active. Match on the name you gave the level in the dashboard; accessLevelId is the server's internal id, not that name:

val isPro = result.data.entitlements.any { it.isActive && it.accessLevelName.equals("pro", ignoreCase = true) }

Every entitlement also carries expiresAt, willRenew, and the store it came from — handy for "your subscription renews on…" messaging. fromCache means the server answered from its own short-lived cache rather than its database; it is never an offline answer — an unreachable server is an Error, and this SDK keeps no copy.

Step 4 — Sell a subscription

Fetch the paywall

Fetch the paywall from the server rather than hard-coding prices in your app. This is what lets you run a sale or change a trial length without a release:

lifecycleScope.launch {
    when (val result = SubSovereign.getPaywallConfig()) {
        is SubSovereignResult.Success -> showPaywall(result.data)   // the paywall published for THIS surface
        is SubSovereignResult.Error   -> showFallbackPaywall()      // your own built-in default
    }
}

PaywallConfig carries the headline, the features, the products to offer (each with a display price, period, trial and an optional badge), the call-to-action, the footer, the colours and the template — everything the screen needs.

Draw it

The SDK draws the paywall. PaywallView is a composable: give it the config and tell it what to do when the customer picks a product.

import androidx.compose.runtime.Composable

@Composable
fun PaywallScreen(config: PaywallConfig, onBuy: (String) -> Unit) {
    PaywallView(
        config = config,
        onDismiss = { /* the customer closed it — only reachable when you allow closing in the console */ },
    ) { productId -> onBuy(productId) }   // start Google Play Billing (or Amazon IAP) for this product
}

It handles the layout, the colours you chose in the console (and keeps the text readable even when a fade would have made it faint), the close button if you allowed one, and — on a television — the ring that shows which control the remote has selected. You start the store's purchase flow for the product it hands you. Putting it on screen is the usual Compose shape:

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent

class PaywallActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent { PaywallScreen(config = paywall) { productId -> startPurchase(productId) } }   // `paywall`: the PaywallConfig you fetched above
    }
}

Complete the purchase, then verify it

Run the purchase through Google Play Billing exactly as you normally would. When Google hands you back a purchaseToken, pass it to SubSovereign so the server can validate the receipt directly with the store and grant the access level. The tier comes from the product-to-level map in your dashboard, never from the app — a modified app cannot escalate itself — so accessLevelId is accepted for compatibility and ignored:

lifecycleScope.launch {
    val ok = SubSovereign.validateGooglePurchase(
        purchaseToken = purchase.purchaseToken,     // from Google Play Billing
        productId     = purchase.products.first(),
        accessLevelId = "pro",                      // required by the SDK; the server grants the tier YOUR DASHBOARD maps this product to
    )
    when (ok) {
        is SubSovereignResult.Success ->
            // Confirmed by the server: re-check (fail-closed) and unlock on the answer, not on hope.
            if (ok.data && SubSovereign.hasAccess("pro")) unlockProFeatures() else showTryAgain("Not confirmed yet")
        is SubSovereignResult.Error   -> showTryAgain(ok.message)
    }
}

That's the whole trust model: the purchase is only real once the server has confirmed it.

On Amazon Fire TV

Fire TV apps use Amazon's In-App Purchasing instead of Google Play. It's the same pattern with different inputs: Amazon gives you a receipt and a user data object, and the receipt is only valid together with Amazon's own user id — which is not your userId. The server maps the SKU to the access level, so there is no accessLevelId to pass:

SubSovereign.validateAmazonPurchase(
    receiptId    = receipt.receiptId,   // from Amazon's PurchaseResponse
    amazonUserId = userData.userId,     // from Amazon's UserData — NOT your own userId
    productId    = receipt.sku,
)

Step 5 — The EU withdrawal function (Compliance Passport)

What happens to the money. When a customer completes this screen, we record the withdrawal and stop the subscription renewing at the end of the period they have already paid for — they keep access until then. For a Stripe subscription we tell Stripe directly, using the keys you saved for this app; you can turn that off under When a customer cancels under EU law on the app's page in the dashboard, and if you do, stopping the renewal becomes your job. Apple, Google, Amazon and Roku do not allow a merchant to cancel on a customer's behalf — for those the customer must also cancel in the store, and you should say so in your own cancellation copy.

Confirming receipt is yours to do, and it is a legal duty. EU law requires the seller to send the customer an acknowledgement of receipt, on a durable medium such as email, without delay, showing the date and time. We do not send it and cannot: we hold no email address for your customers, by design. The customer is signed in to your app, and the successful cancel call returns the withdrawal reference and the exact submission time — send them the letter below from your own system at that moment, and keep a record of when you sent it. On Android the drop-in view keeps the receipt inside its WithdrawalController: build the controller yourself and act when phase becomes Done(receipt), or call withdraw() from your own control.

Subject: Your withdrawal has been received

Hello,

We confirm we received your withdrawal from your subscription on {submittedAt}. Your reference is {withdrawalId}.

Your subscription will not auto-renew. This is your acknowledgement of receipt.

submittedAt and withdrawalId are the two fields the successful cancel call returns.

If you sell subscriptions to EU consumers, Directive (EU) 2023/2673 requires a clearly labelled withdrawal function — the cancel control. The SDK ships it ready-made, and it is drawn in two pieces: fetch the settings for your app, then draw the control with them.

import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue

@Composable
fun CancelSubscription(subscriptionId: String, customerLocale: String) {
    var settings by remember { mutableStateOf<WithdrawalConfig?>(null) }
    LaunchedEffect(Unit) {
        when (val r = SubSovereign.getWithdrawalConfig()) {
            is SubSovereignResult.Success -> settings = r.data
            // Log it: a silent failure here is how you ship English to German customers and never find out.
            is SubSovereignResult.Error -> Log.w("MyApp", "Withdrawal settings unavailable: ${r.message}")
        }
    }
    CancelButton(subscriptionId, settings, customerLocale)
}
@Composable
fun CancelButton(subscriptionId: String, settings: WithdrawalConfig?, customerLocale: String) {
    if (settings?.enabled == false) return       // switched off for this app in the console
    // The WORDS are not passed in and cannot be: getWithdrawalConfig() fetched them in your
    // customer's language and the SDK kept them, and this control reads them from there.
    // Fetch FIRST — without it the notice falls back to English for every customer.
    WithdrawalView(
        subscriptionId = subscriptionId,
        labelKey   = settings?.labelKey,         // ← the label chosen for this app
        locale     = customerLocale,             // ← without this the DATE is formatted for the device
        appearance = Appearance.AUTO,            // LIGHT, DARK or AUTO — and nothing else
    )
}

You cannot change the wording. There are two labels for the withdrawal function — "Withdraw from contract here" and "Cancel here" — and labelKey carries whichever your app is set to; the words themselves come from the server in the customer's language. There is no way to pass your own text: a label parameter used to accept free text and is now ignored with a logged warning. A statutory notice that can be reworded is one that can be softened into marketing, which is the same reason you cannot pass a colour.

🚩 Fetch the settings first, or the statutory notice is in English for everyone. The SDK keeps the words that getWithdrawalConfig() fetched and the control reads them from there — you do not pass them in, and you cannot. A strings parameter is still accepted so nobody's build breaks, but it is ignored, with a warning logged to you: the wording of a legal notice is not the app's to supply, for the same reason the colours are not. The words are served by your SubSovereign server in the languages it ships; a language it does not have comes back English. Which language you get is decided by the locale you configured in Step 2 — the settings call sends it — so customerLocale here must be that same value; it formats the date, it does not choose the words. The control itself does not do network — the same contract has to hold on every platform, including one where a UI component cannot do network at all — so the fetch is yours to make, before the screen appears. Keep the result in state, as the snippet above does: the control reads the words when it draws, and Compose only redraws when state it watches changes, so a control already on screen when the fetch lands will not repaint by itself. If the fetch fails the control still appears, in English, rather than not appearing: failing to put a findable withdrawal function in front of the consumer is your breach; showing it in the wrong language is not. Log the error; never show it to the customer.

🚩 Pass locale too. It formats the timestamp on the acknowledgement. Without it the SDK falls back to the locale you configured; without either, the date is printed in a form no reader can misread. A German customer on a US-locale device must not be shown 9/3/2026, which they will read as 9 March.

Appearance — LIGHT, DARK or AUTO (the default), and nothing else. The control paints its own card and its own text, so it is legible on any screen, light or dark. AUTO follows the device's dark-mode setting on a phone or tablet and is always dark on a television. There is deliberately no way to pass a colour.

It shows the labelled button, confirms once, submits idempotently (a network retry can never create two withdrawals), and shows the acknowledgement with its date. Gating on enabled is yours to do, as above; the control itself does no network beyond the submit.

Step 6 — Consent and privacy

Everything a privacy request needs is one call each, and all of them go through your own server:

SubSovereign.recordConsent(purpose = "analytics", granted = true)
SubSovereign.setDoNotSell(enabled = true)        // CCPA "do not sell" / Global Privacy Control
SubSovereign.requestErasure()                    // GDPR Art. 17 — the customer's right to be forgotten
val export = SubSovereign.exportMyData()         // GDPR Art. 20 — their data, as JSON
val flags  = SubSovereign.getFeatureFlags()      // remote feature flags for this app
SubSovereign.recordAttributionTouch(utmSource = "newsletter")   // where this customer first came from

recordConsent's purpose is one of analytics, marketing, personalisation or consumption_data_sharing — anything else is an Error; jurisdiction defaults to "GDPR" and policyVersion to "1.0", pass your own if they differ. Each returns a SubSovereignResult like every other call.

Working with results

Every SDK call except hasAccess() returns a SubSovereignResult, which is either Success (with the data) or Error (with a message and, where relevant, an HTTP status code). This is deliberate: network calls fail, and your app should decide what to do rather than crash. A simple habit:

  • On Success — use the data.
  • On Error — log it, keep the user on their last-known access, and retry later. Never lock a paying user out because of a momentary network blip.
  • hasAccess() is the exception: it is a plain Boolean and answers false on any error, so use it to gate and checkEntitlements() to explain.

Best practices

  • Check on cold start. Call checkEntitlements() when the app launches so gating is correct before the user reaches a locked feature.
  • Re-check after buying. Right after a successful validate… call, run checkEntitlements() again so the UI reflects the new access immediately.
  • Never trust the client. Don't store "is pro" in the app and treat it as truth. Ask the server; the server verified the receipt.
  • One userId per real user. Keep it stable so access follows the user across devices, and reconfigure when the signed-in user changes.
  • Let the SDK detect the surface. detectPlatform(context) at configure time, never a literal in a build that ships to more than one kind of device.
  • Keep coroutines tidy. These are suspend functions — call them from a lifecycleScope or viewModelScope so they cancel with the screen.

Quick reference

You want to… Call
Set the SDK up SubSovereign.configure(config) with platform = detectPlatform(context)
Gate a feature, fail-closed hasAccess(accessLevelName?)Boolean
See what the user unlocked checkEntitlements()EntitlementResult
Fetch the paywall for this surface getPaywallConfig()PaywallConfig
Draw the paywall PaywallView(config, onDismiss) { productId -> … }
Verify a Google Play purchase validateGooglePurchase(purchaseToken, productId, accessLevelId)
Verify an Amazon (Fire TV) purchase validateAmazonPurchase(receiptId, amazonUserId, productId)
Fetch the withdrawal settings and words getWithdrawalConfig()WithdrawalConfig
Draw the EU withdrawal function WithdrawalView(subscriptionId, labelKey, locale, appearance) — fetch the settings first
Record GDPR consent recordConsent(purpose, granted)
CCPA do-not-sell · erasure · export · flags · attribution setDoNotSell · requestErasure · exportMyData · getFeatureFlags · recordAttributionTouch

Next steps