SubSovereign
All guides

SubSovereign en iOS — guía de integración

Esta guía lleva tu app de Apple (iPhone, iPad, Apple TV o Mac) desde "no tengo ni idea de quién me ha pagado" hasta "mi app desbloquea las funciones correctas para el usuario correcto, verificado en mi propio servidor". Está escrita para seguirse de principio a fin, sin necesidad de experiencia previa con herramientas de suscripciones.

Qué hace SubSovereign por ti

Tus usuarios se suscriben a través de la App Store. SubSovereign responde una pregunta clave para tu app, de forma fiable: ¿qué ha pagado realmente este usuario?

  • Tu app pregunta a SubSovereign por el derecho de acceso del usuario — el acceso que ha desbloqueado.
  • La validación de recibo se realiza en el servidor, directamente con Apple (App Store Server API), por lo que una app modificada no puede falsificar una suscripción.
  • La barrera de pago — la pantalla que ofrece tus planes — se configura de forma remota, por lo que puedes cambiar precios, pruebas y redacción sin enviar una nueva versión de la app.
  • La función de desistimiento (Compliance Passport) — el control de cancelación legal — también lo dibuja el SDK, en el idioma del cliente, para que un aviso legal nunca pueda suavizarse con fines de 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 dispositivo. El dispositivo pregunta; el servidor decide. El mismo código funciona en iOS, tvOS (Apple TV) y macOS.

Antes de empezar

Necesitarás:

  1. Un servidor SubSovereign en ejecución (tu despliegue autohospedado) — el SDK apunta a su URL.
  2. Una app registrada en el panel de control, que te proporciona un appId y una clave de API (el credencial que usa tu app para hablar con el servidor).
  3. Niveles de acceso creados — los niveles de acceso (rangos) que concede tu app, por ejemplo pro, cada uno vinculado en el panel de control a los ID de producto de la App Store que compran tus usuarios.
  4. StoreKit 2 configurado. SubSovereign valida y rastrea compras; no reemplaza StoreKit. Gestiona las compras con StoreKit 2 como de costumbre — SubSovereign interviene justo después de una transacción exitosa para verificarla y registrarla.

En el lado del código necesitas concurrencia en Swift (async/await) y un objetivo de despliegue de iOS 15 / tvOS 15 / macOS 12 o posterior.

Paso 1 — Añadir el SDK

Añade SubSovereign con Swift Package Manager — en Xcode, Archivo ▸ Añadir paquetes… y apunta a el paquete del SDK de SubSovereign (o añádelo a las dependencias de tu Package.swift). Luego impórtalo:

import SubSovereign

Paso 2 — Configurar una sola vez, al iniciar la app

Configura el SDK una sola vez — un buen lugar es el inicializador de tu App, o justo después de que el usuario inicie sesión. Le proporcionas tu clave de API, el ID de la app, la URL de tu servidor, un identificador estable para este usuario y su idioma.

SubSovereign.shared.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:  Locale.current.identifier           // the user's language
    )
)

Hay algunos detalles que conviene conocer:

  • userId es tuyo. Usa cualquier ID estable que ya tengas para un usuario registrado, y el mismo valor cada vez, para que el acceso lo siga a través de sus dispositivos. Si un usuario diferente inicia sesión, vuelve a llamar a configure con el nuevo userId.
  • baseURL apunta a tu servidor — el valor predeterminado del SDK es un marcador de posición; establece tu propio despliegue.
  • El SDK está anotado como @MainActor, por lo que llámalo desde el actor principal (las vistas de SwiftUI y .task funcionan bien).

Paso 3 — Comprobar a qué puede acceder el usuario

Llama a checkEntitlements() para descubrir qué ha desbloqueado el usuario. Hazlo al iniciar la app y de nuevo justo después de una compra. Es una llamada async que puede lanzar, así que envuélvela en do/catch:

do {
    let result = try await SubSovereign.shared.checkEntitlements()
    if result.hasAccess {
        unlockProFeatures()          // the user has paid access
    } else {
        showFreeExperience()         // free tier / show a paywall
    }
} catch {
    // Network hiccup or server error. Fail gracefully — usually keep the user on
    // whatever access they last had, and try again later.
    print("Entitlement check failed: \(error.localizedDescription)")
}

result.hasAccess es el rápido sí/no sobre el resultado que ya obtuviste. Para proteger una función en una sola línea, usa el método del mismo nombre — responde false ante cualquier error, por lo que un fallo de red no abre nada por accidente:

if await SubSovereign.shared.hasAccess(accessLevelName: "pro") {
    unlockProFeatures()
}

Úsalo para una protección barata de una sola función. Para la decisión de acceso en toda la app, usa checkEntitlements() y el consejo sobre errores en la sección Manejo de errores más abajo — durante un corte, quieres que un cliente de pago siga teniendo acceso a lo que ya tenía, no que se quede fuera.

Si tu app tiene más de un rango, mira dentro de result.entitlements — cada uno nombra el nivel de acceso activo. Haz coincidir con el nombre que diste al nivel en el panel de control; accessLevelId es el ID interno del servidor, no ese nombre:

let isPro = result.entitlements.contains { $0.isActive && $0.accessLevelName.lowercased() == "pro" }

Cada derecho de acceso también incluye expiresAt, willRenew y la store de la que proviene. El resultado también incluye fromCache, que indica si el servidor respondió desde su propia caché — el SDK en sí no tiene caché y cada llamada va a tu servidor.

Paso 4 — Vender una suscripción

Mostrar la barrera de pago

Obtén la barrera de pago del servidor en lugar de codificar precios, para que puedas hacer una oferta o cambiar una prueba sin necesidad de lanzar una versión:

if let paywall = try? await SubSovereign.shared.getPaywallConfig() {
    renderPaywall(paywall)   // headline, features, products…
} else {
    renderFallbackPaywall()  // your built-in default
}

PaywallConfig te proporciona un headline, un subheadline, una lista de features, los products para ofrecer (cada uno con un displayPrice, period, trialDays y badge opcional), el texto de llamada a la acción y el texto del pie. Tú construyes la pantalla real — SubSovereign te dice qué decir.

Completa la compra y luego verifícala

Ejecuta la compra a través de StoreKit 2 como lo harías normalmente. Cuando recibas una Transaction verificada, pásasela a SubSovereign para que el servidor pueda validarla directamente con Apple y conceder el nivel de acceso:

func buy(_ product: Product, accessLevelId: String) async throws {
    let result = try await product.purchase()
    guard case .success(let verification) = result else { return }   // cancelled, or pending approval
    guard case .verified(let transaction) = verification else {
        // .unverified means StoreKit could not trust the signature — a tampered receipt.
        // Never grant access, and never let this pass silently.
        print("Refused an unverified transaction")
        return
    }

    let granted = try await SubSovereign.shared.validateApplePurchase(
        transaction: transaction,
        accessLevelId: accessLevelId      // required; the SERVER grants whatever tier your
                                          // dashboard maps this product to, not this value
    )
    if granted {
        await SubSovereign.shared.finishTransaction(transaction)  // tell StoreKit it's done
        _ = try await SubSovereign.shared.checkEntitlements()      // re-check, then unlock
    }
}

Ese es todo el modelo de confianza: la compra solo es real una vez que el servidor la ha confirmado con Apple.

Paso 5 — La función de desistimiento (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 lo incluye listo para usar y se dibuja en dos partes: obtén la configuración para tu app y luego dibuja el control con ella.

struct CancelSubscription: View {
    let subscriptionId: String
    @State private var settings: WithdrawalConfig?

    var body: some View {
        Group {
            if settings?.enabled == false {
                EmptyView()                        // switched off for this app in the console
            } else {
                WithdrawalView(
                    subscriptionId: subscriptionId,
                    strings: settings?.strings,    // ← without this the notice is ENGLISH for everyone
                    appearance: .auto              // .light, .dark or .auto — and nothing else
                )
            }
        }
        .task {
            do {
                settings = try await SubSovereign.shared.getWithdrawalConfig()
            } catch {
                // Log it: a silent failure here is how you ship English to German customers
                // and never find out.
                print("Withdrawal settings unavailable: \(error.localizedDescription)")
            }
        }
    }
}

No puedes cambiar la redacción. Las palabras provienen de tu servidor SubSovereign en el idioma del cliente, y no hay forma de pasar tu propio texto: un parámetro label que antes aceptaba texto libre ahora se ignora, con una advertencia registrada para el integrador. Un aviso legal que pueda reescribirse es uno que puede suavizarse con fines de marketing, por la misma razón por la que no puedes pasar un color.

🚩 Pasa strings, o el aviso legal estará en inglés para todos los clientes. El control renderiza lo que se le pasa y no busca las palabras por sí mismo — el mismo contrato debe cumplirse en todas las plataformas, incluyendo una en la que un componente de interfaz no puede hacer red en absoluto. El idioma que obtienes lo decide el locale que configuraste en el Paso 2, porque la llamada de configuración lo envía. Si la búsqueda falla, el control sigue apareciendo, en inglés, en lugar de no aparecer: no mostrar una función de desistimiento localizable ante el consumidor es tu incumplimiento; mostrarla en el idioma equivocado, no. Registra el error; nunca se lo muestres al cliente.

Apariencia — .light, .dark o .auto (valor predeterminado), y nada más. El control pinta su propia tarjeta y su propio texto, por lo que es legible en cualquier pantalla. .auto sigue la apariencia del dispositivo en un iPhone, iPad o Mac y siempre es oscuro en un Apple TV. No hay forma de pasar un color.

Dos limitaciones en Apple hoy. La etiqueta elegida en la consola aún no se aplica aquí, y la fecha en el acuse de recibo se formatea según la configuración regional del dispositivo en lugar de la del cliente.

Muestra el botón etiquetado, confirma una vez — sin ofrecer retención ni encuesta antes, como exige la ley —, envía idempotentemente (pulsar Intentar de nuevo tras un fallo reutiliza el mismo id, por lo que un reintento no puede crear un segundo desistimiento) y muestra el acuse de recibo con su fecha. El control de enabled depende de ti, como se indicó anteriormente; el control en sí no hace red más allá de la presentación.

Paso 6 — Privacidad y RGPD

Registra el consentimiento donde lo recojas y respeta las expectativas de derechos de datos de Apple — el SDK expone consentimiento, el interruptor de "no vender" de la CCPA, la supresión y la exportación:

try await SubSovereign.shared.recordConsent(purpose: "analytics", granted: true)

// CCPA "do not sell" / Global Privacy Control.
// `true` turns the customer's OPT-OUT on; pass `false` when they opt back in.
try await SubSovereign.shared.setDoNotSell(enabled: true)

// If the user asks to be forgotten / to get their data:
try await SubSovereign.shared.requestErasure()
let myData = try await SubSovereign.shared.exportMyData()

purpose debe ser uno de analytics, marketing, personalisation o consumption_data_sharing — exactamente esos, en minúsculas. Cualquier otro valor lanza .invalidArgument y nada se registra: un registro de consentimiento es un registro legal, y el SDK no creará uno para un propósito sobre el que tu cliente nunca fue preguntado. jurisdiction tiene como valor predeterminado "GDPR" y policyVersion "1.0" — pasa los tuyos si difieren.

Manejo de errores

Toda llamada que lance errores falla con un SubSovereignError tipado — .notConfigured, .networkError(Error), .serverError(code, message) o .invalidArgument(message). Captúralo y decide qué hacer en lugar de dejar que aparezca como un fallo:

  • Éxito — usa el valor.
  • Fallo — regístralo, mantén al usuario con su último acceso conocido, vuelve a intentarlo más tarde. Nunca bloquees a un usuario de pago por un fallo de red momentáneo.

Buenas prácticas

  • Comprueba al iniciar. Llama a checkEntitlements() al iniciar la app (por ejemplo, desde un .task de SwiftUI) para que la protección sea correcta antes de que el usuario llegue a una función bloqueada.
  • Vuelve a comprobar después de comprar. Justo después de una validateApplePurchase exitosa, vuelve a llamar a checkEntitlements() para que la interfaz se actualice al instante.
  • Nunca confíes en el cliente. No guardes "es pro" en la app y trátalo como verdad — pregunta al servidor; el servidor verificó la transacción con Apple.
  • Un userId por usuario real. Manténlo estable para que el acceso siga al usuario en sus dispositivos Apple, y vuelve a configurar cuando cambie el usuario registrado.

Referencia rápida

Quieres… Llama a…
Configurar el SDK SubSovereign.shared.configure(config)
Ver qué desbloqueó el usuario try await checkEntitlements()EntitlementResult
Mostrar la barrera de pago remota try await getPaywallConfig()PaywallConfig
Verificar una compra de StoreKit 2 try await validateApplePurchase(transaction:accessLevelId:)
Finalizar una transacción await finishTransaction(transaction)
Proteger una función, cerrar en caso de fallo await hasAccess(accessLevelName:)Bool
Obtener la configuración de desistimiento try await getWithdrawalConfig()WithdrawalConfig
Dibujar el control de cancelación legal WithdrawalView(subscriptionId:strings:appearance:)
Registrar consentimiento RGPD try await recordConsent(purpose:granted:)
Respetar una solicitud de "no vender" de la CCPA try await setDoNotSell(enabled:)
Suprimir / exportar datos de un usuario try await requestErasure() / exportMyData()

Pasos siguientes