SubSovereign
← All guides

SubSovereign localizzazione su iOS — guida all’integrazione

Questa guida accompagna la tua app Apple (iPhone, iPad, Apple TV o Mac) da “non so chi mi ha pagato” a “la mia app sblocca le funzionalità giuste per l’utente giusto, verificato sul mio server”. È scritta per essere seguita dall’inizio alla fine, senza richiedere esperienza pregressa con strumenti per abbonamenti.

Cosa fa SubSovereign per te

I tuoi utenti si abbonano tramite l’App Store. SubSovereign risponde in modo affidabile a una sola domanda per la tua app: cosa ha effettivamente pagato questo utente?

  • La tua app chiede a SubSovereign il diritto di accesso dell’utente — ciò che ha sbloccato.
  • La validazione della ricevuta avviene sul server, direttamente con Apple (tramite l’App Store Server API), quindi un’app modificata non può falsificare un abbonamento.
  • Il paywall — la schermata che offre i tuoi piani — è configurato da remoto, quindi puoi modificare prezzi, prove gratuite e testi senza pubblicare una nuova versione dell’app.
  • La funzione di recesso dalla legge — il controllo di cancellazione obbligatorio — è gestita anch’essa dall’SDK, nella lingua dell’utente, per evitare che una comunicazione legale venga attenuata in messaggio promozionale.
  • È self-hosted: SubSovereign gira sulla tua infrastruttura, i dati dei tuoi utenti restano con te e non c’è alcuna percentuale sul ricavo — tu trattengo il 100% di ciò che pagano gli utenti.

Non ti fidi del dispositivo. Il dispositivo chiede; il server decide. Lo stesso codice funziona su iOS, tvOS (Apple TV) e macOS.

Prima di iniziare

Avrai bisogno di:

  1. Un server SubSovereign in esecuzione (la tua distribuzione self-hosted) — l’SDK punta al suo URL.
  2. Un’app registrata nella dashboard, che ti fornisce un appId e una chiave API (la credenziale che la tua app usa per comunicare con il server).
  3. Livelli di accesso creati — i livelli di accesso (tier) che la tua app concede, ad esempio pro, ciascuno collegato nella dashboard agli ID dei prodotti dell’App Store che gli utenti acquistano.
  4. StoreKit 2 configurato. SubSovereign valida e traccia gli acquisti; non sostituisce StoreKit. Gestisci gli acquisti con StoreKit 2 come di consueto — SubSovereign interviene subito dopo una transazione andata a buon fine per verificarla e registrarla.

Dal punto di vista del codice, ti servono Swift concurrency (async/await) e un target di distribuzione di iOS 15 / tvOS 15 / macOS 12 o versioni successive.

Passo 1 — Aggiungi l’SDK

Aggiungi SubSovereign tramite Swift Package Manager — in Xcode, vai su File ▸ Aggiungi pacchetti… e punta al pacchetto dell’SDK SubSovereign (o aggiungilo alle dipendenze del tuo Package.swift). Poi importalo:

import SubSovereign

Passo 2 — Configura una sola volta, all’avvio dell’app

Configura l’SDK una sola volta — un buon punto è l’inizializzazione della tua App, o subito dopo l’accesso dell’utente. Forniscigli la tua chiave API, l’ID dell’app, l’URL del tuo server, un identificatore stabile per questo utente e la sua lingua.

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
    )
)

Ecco alcune cose da sapere:

  • userId è tuo. Usa qualsiasi ID stabile che già utilizzi per un utente autenticato e lo stesso valore ogni volta, in modo che l’accesso segua l’utente tra i dispositivi. Se un utente diverso si autentica, richiama configure di nuovo con il nuovo userId.
  • baseURL punta al tuo server — il valore predefinito dell’SDK è un segnaposto; imposta il tuo deployment.
  • L’SDK è annotato con @MainActor, quindi chiamalo dal main actor (le viste SwiftUI e .task sono adatte).

Passo 3 — Verifica cosa può fare l’utente

Chiama checkEntitlements() per scoprire cosa l’utente ha sbloccato. Fallo all’avvio e di nuovo subito dopo un acquisto. È una chiamata async che può generare un errore (throw), quindi avvolgila in un 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 è il sì/no rapido sul risultato che hai già ottenuto. Per proteggere una singola funzionalità in una riga, usa il metodo con lo stesso nome — restituisce false in caso di errore, quindi un problema di rete non sblocca nulla:

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

Usalo per una protezione economica su una singola funzionalità. Per la decisione di accesso a livello di app, usa checkEntitlements() e i consigli su Gestione degli errori qui sotto — durante un’interruzione, vuoi che un cliente pagante rimanga con l’accesso noto per ultimo, non che venga bloccato.

Se la tua app ha più livelli, guarda dentro result.entitlements — ciascuno indica il livello di accesso attivo. Abbinalo al nome che hai assegnato al livello nella dashboard; accessLevelId è l’ID interno del server, non quel nome:

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

Ogni diritto di accesso include anche expiresAt, willRenew e lo store da cui proviene. Il risultato include anche fromCache, che segnala se il server ha risposto dalla sua cache — l’SDK stesso non memorizza cache e ogni chiamata va al tuo server.

Passo 4 — Vendi un abbonamento

Mostra il paywall

Recupera il paywall dal server invece di codificare i prezzi, così puoi avviare una promozione o modificare una prova gratuita senza rilasciare una nuova versione:

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

PaywallConfig ti fornisce una headline, una subheadline, una lista di features, i products da offrire (ognuno con un displayPrice, period, trialDays e un badge opzionale), il testo della call-to-action e il testo del piè di pagina. Puoi costruire tu stesso la schermata da questi dati — o lasciare che l’SDK la disegni. PaywallView renderizza il paywall che hai progettato nella dashboard con SwiftUI nativo, nei colori del tenant, su iPhone, iPad o Apple TV, e ti restituisce il prodotto scelto dal cliente:

PaywallView(config: paywall) { productId in
    // start the StoreKit purchase for `productId` - see "Complete the purchase" below
}

Un colore che non è un colore non raggiunge mai lo schermo: la vista sceglie un’alternativa leggibile e il nostro testo sbiadito viene mantenuto sopra il limite di leggibilità, indipendentemente dai colori che hai scelto.

Completa l’acquisto, poi verificalo

Esegui l’acquisto tramite StoreKit 2 come faresti normalmente. Quando ottieni una Transaction verificata, passala a SubSovereign in modo che il server possa validarla direttamente con Apple e concedere il livello di accesso:

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
    }
}

Questo è l’intero modello di fiducia: l’acquisto è reale solo dopo che il server lo ha confermato con Apple.

Passo 5 — La funzione di recesso dalla legge (Compliance Passport)

Se vendi abbonamenti a consumatori dell’UE, la Direttiva (UE) 2023/2673 richiede una funzione di recesso chiaramente etichettata — il controllo di cancellazione. L’SDK la fornisce già pronta e viene disegnata in due fasi: recupera le impostazioni per la tua app, poi disegna il controllo con esse.

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,
                    labelKey: settings?.labelKey,  // the label you chose in the console
                    appearance: .auto              // .light, .dark or .auto — and nothing else
                )
                // The WORDS are not passed in. getWithdrawalConfig() below fetches them in your
                // customer's language and the SDK keeps them; this screen reads them from there.
                // Fetch FIRST — without it the notice falls back to English for everyone.
            }
        }
        .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)")
            }
        }
    }
}

Non puoi modificare il testo. Le parole provengono dal tuo server SubSovereign nella lingua del cliente e non c’è modo di passare un testo personalizzato: un parametro label che in passato accettava testo libero ora è ignorato, con un avviso registrato nell’integratore. Una comunicazione legale che può essere riformulata è una comunicazione che può essere attenuata in messaggio promozionale, ed è per questo che non puoi passare un colore.

🚩 Passa le strings, altrimenti la comunicazione di recesso sarà in inglese per ogni cliente. Il controllo visualizza ciò che gli viene passato e non recupera le parole da solo — lo stesso contratto deve valere su ogni piattaforma, inclusa quella in cui un componente UI non può fare rete. La lingua che ottieni è decisa dal locale che hai configurato al Passo 2, perché la chiamata delle impostazioni lo invia. Se il recupero fallisce, il controllo appare comunque, in inglese, piuttosto che non apparire: non fornire una funzione di recesso visibile al consumatore è una tua violazione; mostrarla nella lingua sbagliata non lo è. Registra l’errore; non mostrarlo mai al cliente.

Aspetto — .light, .dark o .auto (predefinito), e nient’altro. Il controllo disegna la propria scheda e il proprio testo, quindi è leggibile su qualsiasi schermo. .auto segue l’aspetto del dispositivo su iPhone, iPad o Mac ed è sempre scuro su Apple TV. Non c’è modo di passare un colore.

Un limite su Apple oggi. La data sull’avviso è formattata secondo il locale del dispositivo, non del cliente.

Il label è una tua scelta, non una tua scrittura. labelKey è la scelta che hai fatto nella console tra le due etichette autorizzate — Recedi dal contratto qui o Disdici qui — e il controllo mostra le parole del server per esso nella lingua del cliente. Se disegni tu stesso il controllo invece di WithdrawalView, invia tramite try await SubSovereign.shared.withdraw(subscriptionId:); è esattamente ciò che fa il controllo e restituisce la stessa WithdrawalReceipt.

Mostra il pulsante etichettato, conferma una volta — senza offerte di retention o sondaggi prima, come richiede la legge — invia idempotentemente (premere Riprova dopo un errore riutilizza lo stesso ID, quindi un nuovo tentativo non può creare un secondo recesso), e mostra l’avviso con la sua data. Spetta a te applicare un controllo su enabled, come sopra; il controllo stesso non fa rete oltre alla sottomissione.

Passo 6 — Privacy e GDPR

Registra il consenso dove lo raccogli e rispetta le aspettative di Apple in materia di diritti sui dati — l’SDK espone il consenso, l’interruttore CCPA per la non vendita, la cancellazione e l’esportazione:

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 deve essere uno tra analytics, marketing, personalisation o consumption_data_sharing — esattamente questi, in minuscolo. Tutto il resto genera un errore .invalidArgument e nulla viene registrato: un consenso è un atto legale e l’SDK non registrerà un consenso per uno scopo per cui il cliente non è stato interpellato. jurisdiction predefinito è "GDPR" e policyVersion è "1.0" — passali tuoi se differiscono.

Passo 7 — Feature flag e attribuzione

I flag che imposti nella dashboard e l’attribuzione del primo contatto. Entrambi sono best-effort: non generano mai errori e, in caso di problema, lo segnalano nel log unificato (sottosistema com.subsovereign.sdk) piuttosto che all’utente — un flag mancante, non un booleano, o irraggiungibile viene interpretato come spento.

let flags = await SubSovereign.shared.getFeatureFlags()
if flags["new_paywall"] == true { showNewPaywall() }

// First touch wins on the server, so this is safe to call on every launch.
await SubSovereign.shared.recordAttributionTouch(utmSource: "newsletter", utmCampaign: "spring")

Gestione degli errori

Ogni chiamata che genera errori restituisce un SubSovereignError tipizzato — .notConfigured, .networkError(Error), .serverError(code, message) o .invalidArgument(message). Intercettalo e decidi cosa fare invece di lasciarlo emergere come crash:

  • Successo — usa il valore.
  • Fallimento — registralo, mantieni l’utente con il suo ultimo accesso noto, riprova dopo. Non bloccare mai un utente pagante perché di un problema di rete momentaneo.

Best practice

  • Verifica all’avvio. Chiama checkEntitlements() quando l’app si avvia (ad esempio da un .task SwiftUI) in modo che la protezione sia corretta prima che l’utente raggiunga una funzionalità bloccata.
  • Verifica dopo l’acquisto. Subito dopo una validateApplePurchase andata a buon fine, richiama checkEntitlements() di nuovo in modo che l’interfaccia si aggiorni immediatamente.
  • Non fidarti del client. Non memorizzare “è pro” nell’app e trattarlo come verità — chiedi al server; il server ha verificato la transazione con Apple.
  • Un userId per utente reale. Mantienilo stabile in modo che l’accesso segua l’utente tra i suoi dispositivi Apple e riconfigura quando cambia l’utente autenticato.

Riferimento rapido

Vuoi… Chiama
Configurare l’SDK SubSovereign.shared.configure(config)
Vedere cosa ha sbloccato l’utente try await checkEntitlements() → EntitlementResult
Mostrare il paywall da remoto try await getPaywallConfig() → PaywallConfig
Disegnare il paywall in modo nativo PaywallView(config:onSelectProduct:)
Verificare un acquisto StoreKit 2 try await validateApplePurchase(transaction:accessLevelId:)
Completare una transazione await finishTransaction(transaction)
Proteggere una funzionalità, fail-closed await hasAccess(accessLevelName:) → Bool
Recuperare le impostazioni di recesso try await getWithdrawalConfig() → WithdrawalConfig
Disegnare il controllo di cancellazione obbligatorio WithdrawalView(subscriptionId:strings:labelKey:appearance:)
Inviare un recesso dal tuo controllo try await withdraw(subscriptionId:) → WithdrawalReceipt
Leggere i feature flag della dashboard await getFeatureFlags() → [String: Bool]
Registrare l’attribuzione del primo contatto await recordAttributionTouch(utmSource:utmCampaign:)
Registrare il consenso GDPR try await recordConsent(purpose:granted:)
Rispettare una richiesta CCPA di non vendita try await setDoNotSell(enabled:)
Cancellare / esportare i dati di un utente try await requestErasure() / exportMyData()

Passi successivi