SubSovereign on iOS — integration guide
This guide takes your Apple app (iPhone, iPad, Apple TV, or Mac) 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's written to be followed start to finish — no prior experience with subscription tooling assumed.
What SubSovereign does for you
Your users subscribe through the App Store. 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 Apple (App Store Server API), so a modified app can't fake a subscription.
- The paywall — the screen that offers your plans — is configured remotely, 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 can never be softened 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 device. The device asks; the server decides. The same code runs on iOS, tvOS (Apple TV), and macOS.
Before you start
You'll need:
- A running SubSovereign server (your self-hosted deployment) — the SDK points at its URL.
- An app registered in the dashboard, which gives you an
appIdand an API key (the credential your app uses to talk to the server). - Access levels created — the access levels (tiers) your app grants, e.g.
pro, each linked in the dashboard to the App Store product IDs your users buy. - StoreKit 2 set up. SubSovereign validates and tracks purchases; it does not replace StoreKit. Handle purchases with StoreKit 2 as normal — SubSovereign sits just after a successful transaction to verify and record it.
On the code side you need Swift concurrency (async/await) and a deployment target of iOS 15 /
tvOS 15 / macOS 12 or later.
Step 1 — Add the SDK
Add SubSovereign with Swift Package Manager — in Xcode, File ▸ Add Packages… and point it at
the SubSovereign SDK package (or add it to your Package.swift dependencies). Then import it:
import SubSovereign
Step 2 — Configure once, when your app starts
Configure the SDK a single time — a good place is your App init, or right after the user signs in.
You give it your API key, app ID, your server URL, a stable identifier for this user, and their
language.
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
)
)
A few things worth knowing:
userIdis yours. Use whatever stable ID you already have for a signed-in user, and the same value every time, so access follows them across devices. If a different user signs in, callconfigureagain with the newuserId.baseURLpoints at your server — the SDK default is a placeholder; set your own deployment.- The SDK is annotated
@MainActor, so call it from the main actor (SwiftUI views and.taskare fine).
Step 3 — Check what the user can access
Call checkEntitlements() to find out what the user has unlocked. Do this on launch and again right
after a purchase. It's an async call that can throw, so wrap it in 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 is the quick yes/no on the result you already fetched. To gate a feature in one
line, use the method of the same name — it answers false on any error, so a network blip locks
nothing open:
if await SubSovereign.shared.hasAccess(accessLevelName: "pro") {
unlockProFeatures()
}
Use it for a cheap gate on a single feature. For the app-wide access decision use
checkEntitlements() and the failure advice under Handling errors below — during an outage you
want a paying customer kept on their last-known access, not locked out.
If your app has more than one tier, look inside result.entitlements — each one names the active
access level. Match on the name you gave the level in the dashboard; accessLevelId is the
server's internal id, not that name:
let isPro = result.entitlements.contains { $0.isActive && $0.accessLevelName.lowercased() == "pro" }
Every entitlement also carries expiresAt, willRenew, and the store it came from. The result
also carries fromCache, which reports whether the server answered from its own cache — the SDK
itself holds no cache and every call goes to your server.
Step 4 — Sell a subscription
Show the paywall
Fetch the paywall from the server rather than hard-coding prices, so you can run a sale or change a trial without a release:
if let paywall = try? await SubSovereign.shared.getPaywallConfig() {
renderPaywall(paywall) // headline, features, products…
} else {
renderFallbackPaywall() // your built-in default
}
PaywallConfig gives you a headline, subheadline, a list of features, the products to offer
(each with a displayPrice, period, trialDays, and optional badge), the call-to-action text,
and footer text. You can build the screen yourself from those — or let the SDK draw it. PaywallView
renders the paywall you designed in the dashboard with native SwiftUI, in the tenant's colours, on an
iPhone, iPad or Apple TV, and calls you back with the product the customer chose:
PaywallView(config: paywall) { productId in
// start the StoreKit purchase for `productId` - see "Complete the purchase" below
}
A colour that is not a colour never reaches the screen: the view chooses a readable stand-in, and our own faded text is kept above the legibility floor whatever colours you chose.
Complete the purchase, then verify it
Run the purchase through StoreKit 2 as you normally would. When you get a verified Transaction
back, hand it to SubSovereign so the server can validate it directly with Apple and grant the access
level:
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
}
}
That's the whole trust model: the purchase is only real once the server has confirmed it with Apple.
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 iOS the drop-in WithdrawalView keeps the receipt inside its WithdrawalModel: use your own WithdrawalModel and act when phase becomes .done(receipt), or submit through try await SubSovereign.shared.withdraw(subscriptionId:) 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.
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)")
}
}
}
}
You cannot change the wording. The words come from your SubSovereign server in the customer's
language, and there is no way to pass your own text: a label parameter used to accept free text and
is now ignored, with a warning logged to the integrator. 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 every customer. 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 the integrator: the wording of a legal notice is not
the app's to supply, for the same reason the colours are not. 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 settings in @State, exactly as the snippet above does. The screen reads the words
when it draws, and SwiftUI only redraws when something it watches changes. Assigning to a @State
property is what triggers that redraw, so the words appear as soon as the fetch lands. If you throw
the result away — _ = try await SubSovereign.shared.getWithdrawalConfig() — the SDK still keeps the
words, but a control already on screen will not repaint until something else changes it, and your
customer can see an English button above a translated dialog. Fetch before you show the control, and
hold the result. Which language you
get is decided by the locale you configured in Step 2, because the settings call sends it. 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.
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. .auto follows the device's appearance on
an iPhone, iPad or Mac and is always dark on an Apple TV. There is deliberately no way to pass a
colour.
One limit on Apple today. The date on the acknowledgement is formatted for the device's locale rather than the customer's.
The label is yours to choose, not to write. labelKey is the choice you made in the console
between the two sanctioned labels — Withdraw from contract here or Cancel here — and the control
shows the server's words for it in the customer's language. If you draw your own control instead of
WithdrawalView, submit through try await SubSovereign.shared.withdraw(subscriptionId:); that is
exactly what the control does, and it returns the same WithdrawalReceipt.
It shows the labelled button, confirms once — with no retention offer or survey before it, as the law
requires — submits idempotently (pressing Try again after a failure reuses the same id, so a
retry cannot create a second withdrawal), 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 — Privacy and GDPR
Record consent where you collect it, and honour Apple's data-rights expectations — the SDK exposes consent, the CCPA do-not-sell switch, erasure, and export:
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 must be one of analytics, marketing, personalisation or
consumption_data_sharing — exactly those, lower-case. Anything else throws
.invalidArgument and nothing is recorded: a consent record is a legal record, and the SDK
will not file one against a purpose your customer was never asked about. jurisdiction defaults to
"GDPR" and policyVersion to "1.0" — pass your own if they differ.
Step 7 — Feature flags and attribution
Flags you set in the dashboard, and first-touch attribution. Both are best-effort: they never throw,
and when something is wrong they say so in the unified log (subsystem com.subsovereign.sdk) rather
than to the customer — a flag that is missing, not a boolean, or unreachable reads as off.
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")
Handling errors
Every throwing call fails with a typed SubSovereignError — .notConfigured, .networkError(Error),
.serverError(code, message), .invalidArgument(message) or .invalidResponse(Error). The last one
means the server answered over a working connection with a reply the SDK could not read — a field
missing or of the wrong shape — so retrying will not help; check the server or the SDK version rather
than the connection. Catch it and decide what to do rather
than letting it surface as a crash:
- Success — use the value.
- Failure — log it, keep the user on their last-known access, retry later. Never lock a paying user out because of a momentary network blip.
Best practices
- Check on launch. Call
checkEntitlements()when the app starts (e.g. from a SwiftUI.task) so gating is correct before the user hits a locked feature. - Re-check after buying. Right after a successful
validateApplePurchase, callcheckEntitlements()again so the UI updates 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 transaction with Apple.
- One
userIdper real user. Keep it stable so access follows the user across their Apple devices, and reconfigure when the signed-in user changes.
Quick reference
| You want to… | Call |
|---|---|
| Set the SDK up | SubSovereign.shared.configure(config) |
| See what the user unlocked | try await checkEntitlements() → EntitlementResult |
| Show the remote paywall | try await getPaywallConfig() → PaywallConfig |
| Draw the paywall natively | PaywallView(config:onSelectProduct:) |
| Verify a StoreKit 2 purchase | try await validateApplePurchase(transaction:accessLevelId:) |
| Finish a transaction | await finishTransaction(transaction) |
| Gate a feature, fail-closed | await hasAccess(accessLevelName:) → Bool |
| Fetch the withdrawal settings | try await getWithdrawalConfig() → WithdrawalConfig |
| Draw the statutory cancel control | WithdrawalView(subscriptionId:labelKey:appearance:) — fetch the settings first |
| Submit a withdrawal from your own control | try await withdraw(subscriptionId:) → WithdrawalReceipt |
| Read the dashboard's feature flags | await getFeatureFlags() → [String: Bool] |
| Record first-touch attribution | await recordAttributionTouch(utmSource:utmCampaign:) |
| Record GDPR consent | try await recordConsent(purpose:granted:) |
| Honour a CCPA do-not-sell request | try await setDoNotSell(enabled:) |
| Erase / export a user's data | try await requestErasure() / exportMyData() |
Next steps
- Do the same on your other platforms — the Android, Web/JavaScript, and Roku SDKs follow the identical shape.
- New to the concepts? Read How SubSovereign works.