SubSovereign en Android — guía de integración
Esta guía lleva tu app Android (teléfono, tablet, Android TV o Amazon Fire TV) desde "No tengo ni idea de quién me pagó" hasta "mi app desbloquea las funciones correctas para el usuario correcto, verificadas en mi propio servidor". Está escrita para seguirse de principio a fin — no se asume experiencia previa con herramientas de suscripción. Cada ejemplo de código en Kotlin se compila contra el SDK en cada ejecución de prueba, por lo que lo que lees aquí es lo que hace el código.
Qué hace SubSovereign por ti
Tus usuarios se suscriben a través de la tienda Google Play (o Amazon en Fire TV). SubSovereign responde una pregunta para tu app, de forma fiable: ¿por qué ha pagado realmente este usuario?
- Tu app pregunta a SubSovereign por el derecho de acceso del usuario — lo que ha desbloqueado.
- La validación del recibo ocurre en el servidor, directamente con la tienda, para que una app modificada no pueda falsificar una suscripción.
- El paywall — la pantalla que ofrece tus planes — se configura de forma remota y lo dibuja el SDK, por lo que puedes cambiar precios, períodos de prueba y textos sin lanzar una nueva versión de la app.
- La función de desistimiento de la UE — el control de cancelación legal — también la dibuja el SDK, en el idioma del cliente, para que un aviso legal no se convierta en marketing.
- Es autohospedado: SubSovereign se ejecuta en tu infraestructura, los datos de tus usuarios se quedan contigo y no hay reparto de ingresos — te quedas con el 100% de lo que pagan tus usuarios.
Nunca confíes en el teléfono. El teléfono pregunta; el servidor decide.
Antes de empezar
Necesitarás:
- Un servidor SubSovereign en funcionamiento (tu despliegue autohospedado). Apuntarás el SDK a su URL. Si aún no lo tienes, despliégalo primero — el resto de esta guía asume que está en vivo.
- Una app registrada en el panel de control. En el panel de SubSovereign, crea una app. Obtendrás dos cosas:
- un
appId(identifica tu app), y - una clave API (la credencial que usa tu app para comunicarse con el servidor). Regístrala para la superficie en la que se distribuye — teléfono, Android TV o Fire TV. Un mismo código que se distribuye en más de una superficie está bien; consulta Teléfonos y televisiones en el Paso 2.
- un
- Niveles de acceso creados. En el panel de control, define los niveles de acceso (también llamados tiers) que otorga tu app — por ejemplo
proopremium— y vincula cada uno a los IDs de producto de la tienda que compran tus usuarios. - Facturación de la tienda ya configurada. SubSovereign valida y rastrea compras; no reemplaza Google Play Billing (o Amazon IAP). Configúralos en tu app como de costumbre — SubSovereign se sitúa justo después de la compra para verificarla y registrarla.
En el lado del código necesitas Kotlin con corrutinas (las llamadas del SDK son funciones suspend), Jetpack Compose (el paywall y el control de cancelación son componentes), y minSdk 24 o superior.
Paso 1 — Añadir el SDK
El SDK se distribuye como código fuente por ahora: no hay nada que descargar de Maven Central aún. Copia la carpeta sdk-android/src/main/java del repositorio de SubSovereign en tu proyecto y compílala como parte de tu app — exactamente como hace la app de ejemplo en el mismo repositorio. A continuación se indica lo que necesita el SDK, además de lo que ya tiene tu app: los dos plugins que usa su código, y sus propias dependencias fijadas a las versiones con las que se construye y prueba (la última línea es lo que usa la app de ejemplo para mostrar un componente en pantalla). En AGP 8 también aplica org.jetbrains.kotlin.android; en AGP 9 Kotlin está integrado. Declara las versiones de los plugins en tu archivo de construcción raíz, como hace la app de ejemplo.
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
}
Asegúrate de que tu app tenga el permiso de internet en AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />
Como el SDK se compila como tu propio código fuente, no hay versión que actualizar: para actualizarlo, reemplaza la carpeta. Cuando exista un artefacto publicado, este paso se reducirá a una línea de dependencia y esta guía lo indicará.
Paso 2 — Configurar una sola vez, al iniciar tu app
Configura el SDK una sola vez — el mejor lugar es tu Application.onCreate(), o justo después de que el usuario inicie sesión. Le proporcionas tu clave API, tu ID de app, un identificador estable para este usuario, su idioma y en qué superficie se está ejecutando la app.
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"
)
)
Algunas cosas que vale la pena saber:
userIdes tuyo. Usa el ID estable que ya tengas para un usuario que ha iniciado sesión. Usa el mismo valor cada vez para que el acceso del usuario le siga en todos los dispositivos. Si admites cierre de sesión y un usuario diferente inicia sesión, simplemente vuelve a llamar aconfigurecon el nuevouserId.baseUrlapunta a tu servidor. El valor predeterminado en el SDK es un marcador de posición — configúralo con tu propio despliegue de SubSovereign.localees el idioma del cliente, no el de tu consola, y se decide AQUÍ: el servidor devuelve el paywall y las palabras del aviso legal en el idioma que configures, y el SDK formatea las fechas para él. Tómalo del perfil del cliente o del dispositivo, no de un literal.
Teléfonos y televisiones
Un mismo código de Play puede distribuirse en teléfonos, tablets y Android TV; una compilación para Fire TV se distribuye a través de Amazon. platform le indica al SDK en qué superficie está — "android", "androidtv" o "firetv" — y eso decide tres cosas: qué paywall envía el servidor (un paywall para televisión está diseñado para una habitación, no para una mano), la superficie bajo la que se registra una cancelación, y de dónde se registran tus clientes. detectPlatform(context) lo determina en tiempo de ejecución: un Fire TV por la característica propia del dispositivo de Amazon, un Android TV por su configuración o Leanback, y todo lo demás como teléfono. No codifiques "android" en una compilación que también se ejecute en televisiones — eso obtendría el paywall para teléfono en la TV.
En Fire TV, registra la app como Fire TV en el panel de control, o publica un paywall para Fire TV. Una compilación para Fire TV solicita al servidor el paywall para Fire TV, y un servidor que no lo tenga responde con un Error en lugar del paywall para teléfono (el servidor retrocede de Android TV al paywall para teléfono, y de Fire TV a nada). El SDK nunca adivina una superficie: un valor de platform que no reconoce es un Error que lo nombra, y no se envía nada.
Paso 3 — Comprobar a qué puede acceder el usuario
La respuesta rápida de sí/no es hasAccess(). Falla de forma segura (fail-closed): ante un fallo de red, un error del servidor o un SDK que no se ha configurado, responde false, por lo que una interrupción momentánea nunca puede desbloquear una función de pago. Úsalo en el punto de control de acceso a una función:
lifecycleScope.launch {
if (SubSovereign.hasAccess("pro")) unlockProFeatures() else showPaywall()
}
hasAccess("pro") es verdadero solo cuando un derecho de acceso activo lleva ese nombre de nivel de acceso; hasAccess() sin nombre es el veredicto general del servidor.
Cuando necesites el detalle — qué está activo exactamente, cuándo se renueva, o para distinguir un "no" de un "no se pudo comprobar" — llama a checkEntitlements(). Devuelve un resultado sobre el que decides, y la decisión correcta ante un error suele ser mantener al cliente donde estaba:
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}")
}
}
}
Si tu app tiene más de un nivel, revisa dentro de result.data.entitlements — cada uno te indica exactamente qué nivel de acceso está activo. Haz coincidir el nombre que diste al nivel en el panel de control; accessLevelId es el ID interno del servidor, no ese nombre:
val isPro = result.data.entitlements.any { it.isActive && it.accessLevelName.equals("pro", ignoreCase = true) }
Cada derecho de acceso también incluye expiresAt, willRenew y la store de la que proviene — útil para mensajes como "tu suscripción se renueva el…". fromCache significa que el servidor respondió desde su propia caché de corta duración en lugar de su base de datos; nunca es una respuesta sin conexión — un servidor inalcanzable es un Error, y este SDK no guarda ninguna copia.
Paso 4 — Vender una suscripción
Obtener el paywall
Obtén el paywall del servidor en lugar de codificar los precios en tu app. Esto te permite hacer una oferta o cambiar la duración de un período de prueba sin una nueva versión:
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 incluye el titular, las características, los productos que se ofrecen (cada uno con un precio de visualización, período, prueba y una insignia opcional), el llamado a la acción, el pie de página, los colores y la plantilla — todo lo que necesita la pantalla.
Dibujarlo
El SDK dibuja el paywall. PaywallView es un componente: proporciónale la configuración y dile qué hacer cuando el cliente elija un producto.
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
}
Maneja el diseño, los colores que elegiste en la consola (y mantiene el texto legible incluso cuando un degradado lo habría hecho tenue), el botón de cierre si lo permitiste, y — en una televisión — el anillo que muestra qué control ha seleccionado el mando a distancia. Inicias el flujo de compra de la tienda para el producto que te entrega. Mostrarlo en pantalla sigue la estructura habitual de Compose:
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
}
}
Completar la compra y luego verificarla
Ejecuta la compra a través de Google Play Billing exactamente como lo harías normalmente. Cuando Google te devuelva un purchaseToken, pásalo a SubSovereign para que el servidor pueda validar el recibo directamente con la tienda y otorgar el nivel de acceso. El nivel proviene del mapa producto-nivel en tu panel de control, nunca de la app — una app modificada no puede escalar por sí misma — por lo que accessLevelId se acepta por compatibilidad y se ignora:
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)
}
}
Este es todo el modelo de confianza: la compra solo es real una vez que el servidor la ha confirmado.
En Amazon Fire TV
Las apps de Fire TV usan Amazon In-App Purchasing en lugar de Google Play. Es el mismo patrón con entradas diferentes: Amazon te proporciona un recibo y un objeto user data, y el recibo solo es válido junto con el ID de usuario propio de Amazon — que no es tu userId. El servidor asigna el SKU al nivel de acceso, por lo que no hay accessLevelId que pasar:
SubSovereign.validateAmazonPurchase(
receiptId = receipt.receiptId, // from Amazon's PurchaseResponse
amazonUserId = userData.userId, // from Amazon's UserData — NOT your own userId
productId = receipt.sku,
)
Paso 5 — La función de desistimiento de la UE (Compliance Passport)
Si vendes suscripciones a consumidores de la UE, la Directiva (UE) 2023/2673 exige una función de desistimiento claramente etiquetada — el control de cancelación. El SDK la incluye lista para usar, y se dibuja en dos partes: obtén la configuración para tu app y luego dibuja el control con ella.
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
)
}
No puedes cambiar el texto. Hay dos etiquetas para la función de desistimiento — "Desista del contrato aquí" y "Cancele aquí" — y labelKey lleva la que esté configurada para tu app; las palabras en sí provienen del servidor en el idioma del cliente. No hay forma de pasar tu propio texto: un parámetro label solía aceptar texto libre y ahora se ignora con un aviso registrado. Un aviso legal que puede reescribirse es uno que puede suavizarse hasta convertirse en marketing, que es la misma razón por la que no puedes pasar un color.
🚩 Pasa strings, o el aviso legal aparecerá en inglés para todos. Las palabras las sirve tu servidor SubSovereign en los idiomas que incluye; un idioma que no tenga se devuelve en inglés. El idioma que obtienes lo decide el locale que configuraste en el Paso 2 — la llamada a la configuración lo envía — por lo que customerLocale aquí debe ser ese mismo valor; formatea la fecha, no elige las palabras. El control renderiza lo que se le pasa y no obtiene las palabras por sí mismo, porque el mismo contrato debe cumplirse en todas las plataformas, incluso en una donde un componente de UI no pueda hacer red en absoluto. Si la obtención falla, el control aparece igualmente, en inglés, en lugar de no aparecer: no mostrar una función de desistimiento localizable ante el consumidor es un incumplimiento tuyo; mostrarla en el idioma incorrecto no lo es. Registra el error; nunca se lo muestres al cliente.
🚩 Pasa también locale. Formatea la marca de tiempo en el acuse de recibo. Sin él, el SDK recurre al idioma que configuraste; sin ninguno de los dos, la fecha se imprime en un formato que ningún lector puede malinterpretar. Un cliente alemán en un dispositivo con configuración regional de EE. UU. no debe ver 9/3/2026, que interpretaría como 9 de marzo.
Apariencia — LIGHT, DARK o AUTO (el valor predeterminado), y nada más. El control pinta su propia tarjeta y su propio texto, por lo que es legible en cualquier pantalla, clara u oscura. AUTO sigue la configuración de modo oscuro del dispositivo en un teléfono o tablet y siempre es oscuro en una televisión. No hay forma deliberada de pasar un color.
Muestra el botón etiquetado, confirma una vez, envía de forma idempotente (un reintento de red nunca puede crear dos desistimientos) y muestra el acuse de recibo con su fecha. Controlar si está enabled es responsabilidad tuya, como se indicó anteriormente; el control en sí no hace red más allá del envío.
Paso 6 — Consentimiento y privacidad
Todo lo que necesita una solicitud de privacidad es una llamada para cada cosa, y todas pasan por tu propio servidor:
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
El purpose de recordConsent es uno de analytics, marketing, personalisation o consumption_data_sharing — cualquier otro es un Error; jurisdiction por defecto es "GDPR" y policyVersion es "1.0", pasa los tuyos si difieren. Cada una devuelve un SubSovereignResult como cualquier otra llamada.
Trabajar con resultados
Todas las llamadas del SDK excepto hasAccess() devuelven un SubSovereignResult, que es o bien Success (con los datos) o bien Error (con un mensaje y, cuando es relevante, un código de estado HTTP code). Esto es deliberado: las llamadas de red fallan, y tu app debe decidir qué hacer en lugar de bloquearse. Un hábito sencillo:
- Ante
Success— usa los datos. - Ante
Error— regístralo, mantén al usuario en su último acceso conocido y reintenta más tarde. Nunca bloquees a un usuario que paga por un fallo momentáneo de red. hasAccess()es la excepción: es unBooleansimple y respondefalseante cualquier error, por lo que úsalo para controlar el acceso ycheckEntitlements()para explicar.
Buenas prácticas
- Comprueba al iniciar en frío. Llama a
checkEntitlements()cuando se inicie la app para que el control de acceso sea correcto antes de que el usuario llegue a una función bloqueada. - Vuelve a comprobar después de comprar. Justo después de una llamada exitosa a
validate…, ejecutacheckEntitlements()de nuevo para que la interfaz refleje el nuevo acceso de inmediato. - Nunca confíes en el cliente. No guardes "es pro" en la app y lo trates como verdad. Pregunta al servidor; el servidor verificó el recibo.
- Un
userIdpor usuario real. Mantenlo estable para que el acceso siga al usuario en todos los dispositivos, y vuelve a configurar cuando cambie el usuario que ha iniciado sesión. - Deja que el SDK detecte la superficie.
detectPlatform(context)al configurar, nunca un literal en una compilación que se distribuya en más de un tipo de dispositivo. - Mantén las corrutinas ordenadas. Estas son funciones
suspend— llámalas desde unlifecycleScopeoviewModelScopepara que se cancelen con la pantalla.
Referencia rápida
| Quieres… | Llamada |
|---|---|
| Configurar el SDK | SubSovereign.configure(config) con platform = detectPlatform(context) |
| Controlar acceso, fallar de forma segura | hasAccess(nombreNivelAcceso?) → Boolean |
| Ver qué ha desbloqueado el usuario | checkEntitlements() → EntitlementResult |
| Obtener el paywall para esta superficie | getPaywallConfig() → PaywallConfig |
| Dibujar el paywall | PaywallView(config, onDismiss) { productId -> … } |
| Verificar una compra de Google Play | validateGooglePurchase(purchaseToken, productId, accessLevelId) |
| Verificar una compra de Amazon (Fire TV) | validateAmazonPurchase(receiptId, amazonUserId, productId) |
| Obtener la configuración y palabras del desistimiento | getWithdrawalConfig() → WithdrawalConfig |
| Dibujar la función de desistimiento de la UE | WithdrawalView(subscriptionId, strings, labelKey, locale, appearance) |
| Registrar consentimiento GDPR | recordConsent(purpose, granted) |
| CCPA no vender · borrado · exportación · flags · atribución | setDoNotSell · requestErasure · exportMyData · getFeatureFlags · recordAttributionTouch |
Próximos pasos
- Haz lo mismo en tus otras plataformas — los SDK de iOS, Web/JavaScript y Roku siguen la misma estructura.
- ¿Nuevo en los conceptos? Lee Cómo funciona SubSovereign.