Add subscriptions to a SwiftUI app: StoreKit 2 and RevenueDot

To add subscriptions to a SwiftUI app, create an auto-renewable subscription in App Store Connect, add the RevenueCat iOS SDK with Swift Package Manager, point it at a backend with Purchases.proxyURL, then call offerings(), purchase(package:) and check customerInfo.entitlements. The backend verifies each purchase with Apple and tells the app what the customer may use. This tutorial uses RevenueDot as that backend, so there is no revenue share and no server code to write.

Architecture: a SwiftUI app with the RevenueCat iOS SDK talks to RevenueDot, which talks to the App Store

What you need#

  • An Apple Developer Program account and an app record in App Store Connect with a bundle ID.
  • Xcode and a SwiftUI app. Check the SDK's current minimum iOS version in its README.
  • A RevenueDot Cloud project. Sign up free.
  • An iPhone or simulator, and a sandbox tester account for real store purchases.

The flow has seven steps: create the product, connect the App Store, create the catalog, install the SDK, configure it, build the paywall, then test.

Step 1: Create the subscription in App Store Connect#

Apple's App Store Connect help lists the steps. In short:

  1. In your app, open Monetization, Subscriptions and click + to create a subscription group. Give it a reference name such as Pro.
  2. In the group, click Create and enter a reference name and a Product ID, for example pro_monthly. The product ID is permanent, so choose it with care.
  3. Set the Subscription Duration, the price, and the countries where it is available.
  4. Add a localized display name and description.
  5. Fill in the review information.

Apple says your first auto-renewable subscription must be submitted together with a new app version. Until then, the subscription page offers Add for Review next to the app version.

Common error: the SDK returns an empty offering and logs that products could not be found. The usual causes are a mistyped product ID, a product whose details are incomplete, or a product that is not available in your tester's country. Check each in App Store Connect.

Step 2: Connect the App Store to RevenueDot#

In the RevenueDot dashboard, add an App Store app with your bundle ID. The App Store guide walks through the two things RevenueDot needs.

An In-App Purchase key. RevenueDot uses it to call Apple's App Store Server API and confirm each purchase.

  1. Open App Store Connect, Users and Access, Integrations, In-App Purchase.
  2. Click +, name the key RevenueDot and click Generate.
  3. Download the .p8 file. Apple lets you download it once.
  4. Copy the Key ID and the Issuer ID.
  5. In RevenueDot, open the app, drop in the .p8 file, fill in both IDs and click Check credentials.

Without the key, RevenueDot still verifies StoreKit 2 signed transactions against Apple's root certificate, but it knows only what the device sent: no renewal state and no history.

The notification URL. Apple's App Store Server Notifications tell RevenueDot about renewals, cancellations and refunds when they happen. Copy the app's URL from the dashboard. On RevenueDot Cloud it looks like https://api.revenuedot.app/v1/notifications/apple/{app_id}. In App Store Connect, open your app, then App Information, App Store Server Notifications. Per Apple's setup page, set a Production Server URL and a Sandbox Server URL, and choose Version 2 for each. If you give only a production URL, Apple sends both environments there.

The full list of notification types is in App Store Server Notifications V2.

Step 3: Create the product, entitlement and offering#

Your app should never check a product ID. It checks an entitlement, which is the access a customer has. In RevenueDot's dashboard:

  1. Products: add pro_monthly for the App Store app, type subscription, one month.
  2. Entitlements: add pro and attach the product.
  3. Offerings: create default with a $rc_monthly package that holds the product, and make it current.

The products and entitlements and offerings and packages pages explain each field. Later you can add a yearly plan to the same entitlement without shipping an app update.

RevenueDot dashboard page listing an offering with its packages and attached products

Step 4: Install the SDK#

In Xcode, choose File, Add Package Dependencies and enter the SDK's package URL. RevenueCat's installation guide gives https://github.com/RevenueCat/purchases-ios-spm.git. Add the RevenueCat product to your app target.

RevenueDot also maintains a fork that keeps the same Swift module names, so import RevenueCat stays. It is not published to a package registry yet, so this tutorial uses the stock SDK in proxy mode.

Step 5: Configure the SDK with a proxy URL#

Set the proxy URL before you call configure. Turn off entitlement verification, because the stock SDK checks response signatures against RevenueCat's key and would log every RevenueDot response as failed.

Swift
import RevenueCat
import SwiftUI

@main
struct FocusApp: App {
    init() {
        Purchases.logLevel = .debug
        // Point the SDK at RevenueDot. Set it before configure.
        Purchases.proxyURL = URL(string: "https://api.revenuedot.app")!
        Purchases.configure(
            with: Configuration.Builder(withAPIKey: "appl_YourPublicKey")
                .with(entitlementVerificationMode: .disabled)
                .build()
        )
    }

    var body: some Scene {
        WindowGroup { ContentView() }
    }
}

Use the app's public key from RevenueDot (appl_...). If you ran the importer from RevenueCat, your existing key keeps working. For development without any Apple account, create a Test Store app and use its test_... key. Test Store keys work only in Debug builds: in a Release build the SDK shows a "Wrong API Key" alert and stops the app on purpose. Ship with the appl_ key.

Expected output: in Xcode's console, a debug log shows the SDK's requests going to api.revenuedot.app, and Purchases is configured.

Step 6: Load offerings, buy, restore and check access#

Keep the purchase logic in one observable model. These calls are the SDK's public API, unchanged.

Swift
import RevenueCat
import SwiftUI

@MainActor
final class PaywallModel: ObservableObject {
    @Published var packages: [Package] = []
    @Published var customerInfo: CustomerInfo?
    @Published var message: String?

    var isPro: Bool { customerInfo?.entitlements["pro"]?.isActive == true }

    func load() async {
        do {
            let offerings = try await Purchases.shared.offerings()
            packages = offerings.current?.availablePackages ?? []
            customerInfo = try await Purchases.shared.customerInfo()
        } catch {
            message = error.localizedDescription
        }
    }

    // Keeps the screen in sync after renewals, restores and purchases on other devices.
    func watch() async {
        for await info in Purchases.shared.customerInfoStream { customerInfo = info }
    }

    func buy(_ package: Package) async {
        do {
            let result = try await Purchases.shared.purchase(package: package)
            if !result.userCancelled { customerInfo = result.customerInfo }
        } catch ErrorCode.purchaseCancelledError {
            // The customer closed the sheet. Show nothing.
        } catch {
            message = error.localizedDescription
        }
    }

    func restore() async {
        do { customerInfo = try await Purchases.shared.restorePurchases() }
        catch { message = error.localizedDescription }
    }
}

Then the SwiftUI view:

Swift
struct PaywallView: View {
    @StateObject private var model = PaywallModel()

    var body: some View {
        VStack(spacing: 16) {
            if model.isPro {
                Text("You have Pro").font(.title2)
            } else {
                ForEach(model.packages, id: \.identifier) { package in
                    Button {
                        Task { await model.buy(package) }
                    } label: {
                        Text("\(package.storeProduct.localizedTitle), \(package.localizedPriceString)")
                            .frame(maxWidth: .infinity)
                    }
                    .buttonStyle(.borderedProminent)
                }
                Button("Restore purchases") { Task { await model.restore() } }
            }
            if let message = model.message { Text(message).foregroundStyle(.red) }
        }
        .padding()
        .task { await model.load() }
        .task { await model.watch() }
    }
}

Put a visible Restore purchases button, terms and a privacy link on your paywall, as RevenueDot's example app does. RevenueCat advises calling restore only from a button. The restore guide explains who owns a restored purchase when two accounts share an Apple ID.

What happens when the customer taps buy#

Sequence diagram: the app asks RevenueDot for offerings, StoreKit shows the sheet, the SDK posts the signed transaction to RevenueDot, RevenueDot verifies it with Apple and returns customer info

  1. offerings() calls GET /v1/subscribers/{id}/offerings on RevenueDot and loads the matching products from StoreKit.
  2. purchase(package:) shows Apple's payment sheet.
  3. The SDK posts the signed transaction to POST /v1/receipts.
  4. RevenueDot verifies the signature and, with the In-App Purchase key, reads the customer's history from Apple.
  5. RevenueDot answers with customer info. The pro entitlement is now active, and your webhook receives an INITIAL_PURCHASE event.

Step 7: Test in the sandbox#

  1. In App Store Connect, open Users and Access, Sandbox and create a sandbox tester.
  2. On the device, sign in with it under Settings, App Store, Sandbox Account. TestFlight builds also use sandbox purchases.
  3. Run the app with the appl_ key. Buy the plan. Sandbox subscriptions renew in minutes, so you can watch renewals and expirations arrive.
  4. Check the customer in the RevenueDot dashboard. The purchase is marked sandbox.
  5. For StoreKit testing inside Xcode, save the StoreKit test certificate on the app. See sandbox testing.

Run a sandbox purchase before you ship, and tell us what you find in a GitHub issue.

Common errors and fixes#

Symptom Cause Fix
Empty offering Product ID mismatch or product not ready Match the ID exactly in RevenueDot's product and App Store Connect
"Wrong API Key" alert A test_ key in a Release build Use the appl_ key for Release
Purchase succeeds, entitlement not active Product not attached to the pro entitlement Attach it in the dashboard. See entitlement not active
Purchase retries forever RevenueDot answered 5xx, for example a missing In-App Purchase key (code 7234) Add the key. A 5xx tells the SDK to keep the transaction and retry. See 4xx vs 5xx
Logs say signature verification failed Verification left on with the stock SDK Set .disabled as in Step 5
No notifications arrive Wrong URL, or the Sandbox URL is empty See notifications not arriving

Do it with RevenueDot#

  1. Create a free account. Cloud is free up to $10,000 in monthly tracked revenue.
  2. Add your App Store app, the In-App Purchase key and the notification URL (Step 2).
  3. Create the product, the pro entitlement and the default offering (Step 3).
  4. Set Purchases.proxyURL to https://api.revenuedot.app and configure with your appl_ key (Step 5).
  5. Add a webhook under Integrations, Webhooks to tell your own backend about purchases.

If you already use RevenueCat, the migration guide shows how to move without losing a subscriber.

Start free on RevenueDot Cloud

FAQ#

Do I need a server to add subscriptions to a SwiftUI app?#

You need something that verifies purchases and keeps entitlement state, and you can use a hosted or self-hosted backend for that. RevenueDot does it for you: the SDK posts each purchase, and RevenueDot checks it with Apple and returns the customer's entitlements.

Is StoreKit 2 required?#

You do not call StoreKit 2 yourself when you use the SDK. RevenueDot verifies StoreKit 2 signed transactions against Apple's certificate chain, and it needs the In-App Purchase key to read history and renewal state. StoreKit 1 receipts also need that key.

How do I check whether a user is subscribed in SwiftUI?#

Read customerInfo.entitlements["pro"]?.isActive. Call Purchases.shared.customerInfo() for a one-time read, or loop over Purchases.shared.customerInfoStream to update the screen when the state changes.

What URL do I give App Store Connect for server notifications?#

Use the app's notification URL from RevenueDot, in the form https://api.revenuedot.app/v1/notifications/apple/{app_id}. Set it for production and sandbox, and choose Version 2.

Can I test without an Apple account?#

Yes. Create a Test Store app in RevenueDot and use its test_ key in a Debug build. The SDK shows a Test Store dialog instead of Apple's sheet. It proves your code path, not Apple's.

About RevenueDot. RevenueDot is an open-source (AGPL-3.0) backend for in-app purchases and subscriptions that works with the RevenueCat SDK. Start free on RevenueDot Cloud, free up to $10,000 in monthly tracked revenue, or self-host it with Docker and Postgres. Point the SDK's proxy URL at RevenueDot and keep your app code, your offerings and your customers. Read the quickstart or the code on GitHub.