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:
- 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.
- 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.
- an
- Access levels created. In the dashboard, define the access levels (also called
tiers) your app grants — for example
proorpremium— and link each to the store product IDs your users buy. - 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:
userIdis 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 callconfigureagain with the newuserId.baseUrlpoints at your server. The default in the SDK is a placeholder — set this to your own SubSovereign deployment.localeis 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 plainBooleanand answersfalseon any error, so use it to gate andcheckEntitlements()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, runcheckEntitlements()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
userIdper 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
suspendfunctions — call them from alifecycleScopeorviewModelScopeso 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
- Do the same on your other platforms — the iOS, Web/JavaScript, and Roku SDKs follow the identical shape.
- New to the concepts? Read How SubSovereign works.