Add subscriptions to your iOS app in 4 easy steps

Start charging for your app without writing payment code. No bloat, no account, completely free.

ContentView.swift
struct ContentView: View {
    var body: some View {
        TabView {
            HomeView()
                .tabItem { Label("Home", systemImage: "house") }
            ProfileView()
                .tabItem { Label("Profile", systemImage: "person") }
        }
        .requiresAccess {
            YourPaywallView() // your paywall
        }
    }
}
class TabBarController: UITabBarController {
    override func viewDidLoad() {
        super.viewDidLoad()
        viewControllers = [
            HomeViewController(),
            ProfileViewController()
        ]
        requiresAccess {
            YourPaywallViewController() // your paywall
        }
    }
}

One modifier to gate your entire app.

No account required Completely free Lightweight

Set Up Your Products

Choose a unique name for each subscription plan you want to sell. These Product IDs are how Apple identifies your products — you'll create matching entries in App Store Connect later, and Payo uses them to load pricing, handle purchases, and check access.

Where to find your bundle ID: In Xcode, select your target → General tab → Bundle Identifier. Don't have Product IDs yet? Use placeholder names — you can update them later.

Install the Package

Add the Payo SPM package to your Xcode project.

1

In Xcode, go to File > Add Package Dependencies...

2

Paste the Payo repository URL:

URL
https://github.com/PayoSDK/payo-ios
3

Set the dependency rule to Up to Next Major Version and click Add Package.

Payo requires iOS 15+.

Download Config File

Download this file, then drag it into your Xcode project navigator. When prompted, make sure "Copy items if needed" is checked.

One Last Step

How would you like to test?

Test Locally

1

Download and drag into your Xcode project navigator. Make sure "Copy items if needed" is checked.

2

Go to Product > Scheme > Edit Scheme > Run > Options and select Products.storekit from the StoreKit Configuration dropdown.

3

Build and run. Payo reads Payo.plist automatically and loads products from the local StoreKit config — no setup code needed.

Products.storekit is for local testing only. It works in the simulator and on-device debug builds, but won't exist in production. When you're ready to ship, follow our App Store Connect guide.

Set Up App Store Connect

Create matching products in App Store Connect. Your product IDs must match exactly:

Your Product IDs
1

Open App Store Connect and select your app. In the sidebar under Monetization, select Subscriptions.

2

In the Subscription Groups section, click Create to make a new group (e.g., "Premium"). A subscription group ties related plans together — users can only have one active subscription per group, and they can upgrade or downgrade between plans within the same group.

3

Inside your group, click Create in the Subscriptions section. Enter a Reference Name (internal only — e.g., "Pro Monthly"), then paste one of your Product IDs shown above into the Product ID field. Click the pill to copy it.

4

On the product page, select a Subscription Duration (e.g., 1 Month). Then scroll to Subscription Prices and click Add Subscription Price to set your price — Apple will auto-calculate pricing for all other countries.

5

Scroll to Localization and click Add Localization. Choose your language (e.g., English), enter a Display Name (e.g., "Pro Monthly") and a Description (e.g., "Unlimited access to all premium features"), then click Add. Apple requires at least one localization.

6

Repeat steps 3–5 for each plan you want to offer (e.g., monthly + annual). Make sure each uses a different Product ID from the list above.

7

(Optional) To add a free trial or intro price, open the product and scroll to Introductory Offers. Click +, choose the offer type (free trial, pay-as-you-go, or pay up front), set the duration, and save. Payo detects and displays these automatically.

You're all set

Payo is configured and ready to go. How would you like to start building?

API Reference

Tap a scenario to see documentation, code examples, and best practices.

Purchase a Product

Trigger a purchase flow for a subscription or one-time purchase. Payo handles the StoreKit transaction, verifies it, and updates access automatically.

When to use this

Call this when a user taps a "Subscribe" or "Buy" button on your paywall. Payo presents the system payment sheet, processes the result, and returns transaction details.

Payo.purchase(_ productID: String) async throws → PurchaseInfo

Returns

PurchaseInfo contains: productID, transactionID, purchaseDate, expirationDate (nil for lifetime), and originalTransactionID.

Swift — SwiftUI
Button("Subscribe for \(product.displayPrice)") {
    Task {
        do {
            let info = try await Payo.purchase("pro_monthly")
            print("Purchased! Expires: \(info.expirationDate ?? .now)")
        } catch let error as PayoError {
            switch error {
            case .userCancelled:
                break // user tapped Cancel — do nothing
            case .purchasePending:
                showAlert("Purchase pending approval.")
            default:
                showAlert("Error: \(error.localizedDescription)")
            }
        }
    }
}
Swift — UIKit
@IBAction func subscribeTapped(_ sender: Any) {
    Task {
        do {
            let info = try await Payo.purchase("pro_monthly")
            print("Purchased! Expires: \(info.expirationDate ?? .now)")
        } catch let error as PayoError {
            switch error {
            case .userCancelled:
                break // user tapped Cancel — do nothing
            case .purchasePending:
                showAlert("Purchase pending approval.")
            default:
                showAlert("Error: \(error.localizedDescription)")
            }
        } catch {
            showAlert("Error: \(error.localizedDescription)")
        }
    }
}

private func showAlert(_ message: String) {
    let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert)
    alert.addAction(UIAlertAction(title: "OK", style: .default))
    present(alert, animated: true)
}

Handle .userCancelled separately. When a user dismisses the payment sheet, Payo throws PayoError.userCancelled. Don't show an error alert for this — it's expected behavior.

Verification is automatic. Payo verifies every transaction with Apple and finishes it with StoreKit behind the scenes. You only handle the result or errors — no manual receipt validation needed.

Check & Gate Access

Gate features behind a subscription. Use the one-liner view modifier for the simplest approach, or check access manually for full control.

One-liner gate (recommended)

Lock any view behind subscription access with a single modifier or method call. Content is blurred with a lock icon overlay until the user subscribes. Fully reactive — the overlay appears and disappears automatically as access changes from purchases, restores, renewals, expirations, and app foregrounding. No state observation code needed.

.requiresAccess() → some View
Swift — SwiftUI
// Default — blur + lock overlay
GroupBox("Premium Analytics") {
    AnalyticsChart()
}
.requiresAccess()

// Custom message and icon
GroupBox("Pro Features") {
    ProDashboard()
}
.requiresAccess("Pro Feature", icon: "star.fill")

// Fully custom overlay
GroupBox("Premium") {
    AnalyticsChart()
}
.requiresAccess {
    VStack {
        Image(systemName: "crown.fill")
            .font(.title)
        Text("Upgrade to Pro")
            .font(.headline)
    }
}

// Full-screen paywall — overlay is fully interactive
TabView {
    HomeView()
    SettingsView()
}
.requiresAccess {
    MyPaywallView() // buttons, links, etc. all work
}

// Gate behind a specific group (for multi-tier apps)
GroupBox("Premium Features") {
    PremiumDashboard()
}
.requiresAccess(group: "premium")
UIView.setRequiresAccess(group: String? = nil)
Swift — UIKit
override func viewDidLoad() {
    super.viewDidLoad()

    // Gate a view — adds blur + lock overlay automatically
    premiumView.setRequiresAccess()

    // Gate behind a specific group
    analyticsView.setRequiresAccess(group: "premium")
}

// To remove the gate later (e.g. on deinit)
premiumView.removeRequiresAccess()

Manual check (custom behavior)

If you need custom logic beyond the blur overlay — like showing a paywall, navigating to a different screen, or disabling specific controls — check access directly.

Payo.hasAccess → Bool
Payo.hasAccess(_ group: String) → Bool
Swift
// Check access to any configured product
if Payo.hasAccess {
    // unlock pro features
} else {
    // show paywall
}

// Check access to a specific group (for multi-tier apps)
if Payo.hasAccess("premium") {
    // unlock premium-only features
}

Reactive UI (custom behavior)

For custom UI that should react to access changes in real time, observe Payo.state directly.

Payo.state → PayoState (ObservableObject)
Swift — SwiftUI
struct ContentView: View {
    @ObservedObject var billing = Payo.state

    var body: some View {
        if billing.hasAccess {
            ProFeatureView()
        } else {
            PaywallView()
        }
    }
}
Swift — Combine
import Combine

class ViewController: UIViewController {
    private var cancellable: AnyCancellable?

    override func viewDidLoad() {
        super.viewDidLoad()
        cancellable = Payo.state.$hasAccess
            .receive(on: DispatchQueue.main)
            .sink { [weak self] hasAccess in
                if hasAccess {
                    // show pro features
                } else {
                    // show upgrade prompt
                }
            }
    }
}

Fully reactive. All three approaches automatically update when the user purchases, restores, a subscription renews or expires, or the app returns to foreground. The SDK detects all state changes internally — no polling or manual refresh needed.

Display Product Info

Fetch localized product names, descriptions, and prices from the App Store to display on your paywall. Prices are already formatted for the user's locale and currency.

When to use this

Call this when building your paywall or pricing screen. Never hard-code prices — the App Store provides localized pricing that varies by country.

All products

Payo.allProductInfo() async → [ProductInfo]

Single product

Payo.productInfo(_ productID: String) async → ProductInfo?

ProductInfo fields

Swift — ProductInfo
// ProductInfo properties:
product.id            // "pro_monthly"
product.displayName   // "Pro Monthly"
product.description   // "Unlock all pro features"
product.displayPrice  // "$4.99" (localized)
product.price         // 4.99 (Decimal)
product.introOffer    // IntroOffer? (free trial info)
Swift — SwiftUI
struct PaywallView: View {
    @State private var products: [ProductInfo] = []

    var body: some View {
        VStack(spacing: 16) {
            ForEach(products, id: \.id) { product in
                Button {
                    Task { try? await Payo.purchase(product.id) }
                } label: {
                    HStack {
                        VStack(alignment: .leading) {
                            Text(product.displayName).font(.headline)
                            Text(product.description).font(.caption)
                        }
                        Spacer()
                        Text(product.displayPrice)
                            .font(.headline)
                    }
                    .padding()
                    .background(.ultraThinMaterial)
                    .cornerRadius(12)
                }
            }
        }
        .task {
            products = await Payo.allProductInfo()
        }
    }
}
Swift — UIKit
class PaywallViewController: UITableViewController {
    private var products: [ProductInfo] = []

    override func viewDidLoad() {
        super.viewDidLoad()
        Task {
            products = await Payo.allProductInfo()
            tableView.reloadData()
        }
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        products.count
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        let product = products[indexPath.row]
        cell.textLabel?.text = product.displayName
        cell.detailTextLabel?.text = product.displayPrice
        return cell
    }

    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let product = products[indexPath.row]
        Task { try? await Payo.purchase(product.id) }
    }
}

Prices are already localized. displayPrice returns a formatted string like "$4.99" or "4,99 €" based on the user's App Store country. Never format prices manually.

Auto-fetched from the App Store. Product info is fetched and cached during configuration. The SDK extracts localized names, prices, billing periods, and intro offers automatically — no manual data mapping needed.

Intro Offer Eligibility

Check if the user qualifies for an introductory offer (free trial, discounted first period, or pay-up-front) and display it on your paywall.

When to use this

Call this when loading your paywall to decide whether to show a "Free Trial" or "Special Offer" badge. Apple only allows intro offers for first-time subscribers within a subscription group.

Payo.isEligibleForIntroOffer() async → Bool

IntroOffer fields

Swift — IntroOffer
// IntroOffer properties (from ProductInfo.introOffer):
offer.displayPrice  // "Free" or "$0.99"
offer.periodCount   // 1
offer.periodUnit    // .week, .month, .year
offer.periodValue   // 7 (days)
offer.paymentMode   // .freeTrial, .payAsYouGo, .payUpFront
Swift — SwiftUI
struct PaywallView: View {
    @State private var products: [ProductInfo] = []
    @State private var introEligible = false

    var body: some View {
        VStack {
            if introEligible, let offer = products.first?.introOffer {
                Text("Start your free \(offer.periodValue)-day trial!")
                    .font(.headline)
                    .foregroundStyle(.green)
            }

            ForEach(products, id: \.id) { product in
                Button(product.displayName) {
                    Task { try? await Payo.purchase(product.id) }
                }
            }
        }
        .task {
            products = await Payo.allProductInfo()
            introEligible = await Payo.isEligibleForIntroOffer()
        }
    }
}
Swift — UIKit
class PaywallViewController: UIViewController {
    @IBOutlet weak var trialBadge: UILabel!

    override func viewDidLoad() {
        super.viewDidLoad()
        Task {
            let products = await Payo.allProductInfo()
            let eligible = await Payo.isEligibleForIntroOffer()

            if eligible, let offer = products.first?.introOffer {
                trialBadge.text = "Start your free \(offer.periodValue)-day trial!"
                trialBadge.isHidden = false
            } else {
                trialBadge.isHidden = true
            }
        }
    }
}

Check eligibility per group. For multi-tier apps, use Payo.isEligibleForIntroOffer("pro") to check a specific subscription group.

Eligibility is automatic. The SDK checks against the user's full purchase history in StoreKit. Apple's intro offer rules are applied automatically — no manual eligibility logic needed.

Restore Purchases

Sync the user's previous purchases with the App Store. This re-activates access if they've already paid — essential when switching devices or reinstalling.

When to use this

Add a "Restore Purchases" button to your paywall or settings screen. Apple requires this for App Store review — your app will be rejected without it.

Payo.restorePurchases() async throws
Swift — SwiftUI
Button("Restore Purchases") {
    Task {
        do {
            try await Payo.restorePurchases()
            // Access is automatically updated
        } catch {
            showAlert("Restore failed: \(error.localizedDescription)")
        }
    }
}
Swift — UIKit
@IBAction func restoreTapped(_ sender: Any) {
    Task {
        do {
            try await Payo.restorePurchases()
            // Access is automatically updated
        } catch {
            let alert = UIAlertController(
                title: "Error",
                message: "Restore failed: \(error.localizedDescription)",
                preferredStyle: .alert
            )
            alert.addAction(UIAlertAction(title: "OK", style: .default))
            present(alert, animated: true)
        }
    }
}

Required by Apple. Every app with subscriptions must include a restore button. After restoring, Payo.hasAccess and Payo.state update immediately, the UI re-renders, .requiresAccess() overlays unlock, and the expiration timer resets — all automatically.

Manage Subscriptions

Open Apple's built-in subscription management sheet where users can upgrade, downgrade, or cancel their subscription.

When to use this

Add a "Manage Subscription" button in your settings or account screen. Apple requires this for App Store review — users must be able to manage their subscription from within your app.

Payo.showManageSubscriptions() async throws
Swift — SwiftUI
Button("Manage Subscription") {
    Task {
        try? await Payo.showManageSubscriptions()
    }
}
Swift — UIKit
@IBAction func manageTapped(_ sender: Any) {
    Task {
        try? await Payo.showManageSubscriptions()
    }
}

Request a refund

Let users request a refund directly in your app. Apple shows their native refund sheet — no custom UI needed. If Apple approves the refund, Payo automatically revokes access.

Payo.beginRefundRequest(_ productID: String) async throws → Transaction.RefundRequestStatus
Swift — SwiftUI
Button("Request Refund") {
    Task {
        let status = try? await Payo.beginRefundRequest("pro_monthly")
        if status == .success {
            // Apple is reviewing the request
        }
    }
}
Swift — UIKit
@IBAction func refundTapped(_ sender: Any) {
    Task {
        let status = try? await Payo.beginRefundRequest("pro_monthly")
        if status == .success {
            // Apple is reviewing the request
        }
    }
}

Automatic access revocation. When Apple approves a refund, the SDK detects it via the transaction observer and immediately revokes access. Payo.hasAccess, .requiresAccess(), and all reactive state update automatically.

Multi-Tier Access

Support multiple subscription tiers (e.g. "Pro" and "Premium") by configuring with named groups and checking access per group.

When to use this

If your app has different feature levels — for example, a "Pro" plan that unlocks basic features and a "Premium" plan that unlocks everything — use named groups so you can gate each set of features independently.

Check access per group

Payo.hasAccess(_ group: String) → Bool
Swift — SwiftUI
struct FeatureView: View {
    var body: some View {
        VStack {
            if Payo.hasAccess("pro") {
                ProFeaturesView()
            }

            if Payo.hasAccess("premium") {
                PremiumFeaturesView()
            }

            if !Payo.hasAccess {
                PaywallView()
            }
        }
    }
}
Swift — UIKit
class FeatureViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        if Payo.hasAccess("pro") {
            setupProFeatures()
        }

        if Payo.hasAccess("premium") {
            setupPremiumFeatures()
        }

        if !Payo.hasAccess {
            showPaywall()
        }
    }
}

One subscription per group. Apple only allows one active subscription within a subscription group. Use separate groups for independent tiers. Payo.hasAccess (no argument) returns true if the user has access to any group.

Auto-detected groups. When using flat-list configuration, Payo automatically groups subscriptions by their StoreKit subscription group ID. Named groups give you explicit control over the grouping.

Refresh & Reset

Manually re-check the user's entitlements or clear all billing state.

Refresh entitlements

Forces Payo to re-query StoreKit for the user's current purchases. You rarely need this — the SDK automatically refreshes on foreground return, transaction updates, and subscription expiration. Mainly useful during development or sandbox testing.

Payo.refreshEntitlements() async
Swift
// Re-check purchases (e.g., after testing in Xcode Sandbox)
await Payo.refreshEntitlements()

Reset all state

Clears all billing state, stops the transaction observer, and re-initializes from Payo.plist. Useful when a user logs out of your app.

Payo.reset() async
Swift — SwiftUI
Button("Log Out") {
    Task {
        await Payo.reset()
        // Automatically re-initializes from Payo.plist
    }
}
Swift — UIKit
@IBAction func logOutTapped(_ sender: Any) {
    Task {
        await Payo.reset()
        // Automatically re-initializes from Payo.plist
    }
}

Debug logging. Debug logging is enabled by default. You'll see detailed [Payo] console logs during development — product loading, purchase flow, access changes, foreground refreshes, and expiration timers. Call Payo.enableDebug(false) to turn it off for production.

PayoPurchaseButton

A drop-in SwiftUI button that auto-fetches product info, displays a smart label (with intro offer awareness), handles the purchase flow, and reports the result — all in one line of code.

Default smart label

The button automatically fetches the product's price and intro offer eligibility, then renders the right label: a free trial CTA, an intro price, or the standard subscription price.

PayoPurchaseButton(_ productID: String)
Swift — SwiftUI
// Smart label — auto-fetches price + intro offer
// Renders: "Start 7-Day Free Trial" or "Subscribe — $4.99"
PayoPurchaseButton("pro_monthly")
    .buttonStyle(.borderedProminent)

With callbacks

Handle success and error events with optional closures. The button silently ignores user cancellation.

Swift — SwiftUI
PayoPurchaseButton("pro_monthly", onPurchase: { info in
    print("Purchased! Expires: \(info.expirationDate ?? .now)")
}, onError: { error in
    print("Failed: \(error.localizedDescription)")
})
.buttonStyle(.borderedProminent)

Custom label

Use a ViewBuilder closure to build any label you want. Your closure receives the loaded ProductInfo and a Bool indicating intro offer eligibility.

Swift — SwiftUI
PayoPurchaseButton("pro_monthly") { product, isEligibleForTrial in
    HStack {
        VStack(alignment: .leading) {
            Text(product.displayName)
                .font(.headline)
            if isEligibleForTrial {
                Text("Free trial available")
                    .font(.caption)
                    .foregroundStyle(.green)
            }
        }
        Spacer()
        Text(product.displayPrice)
            .fontWeight(.semibold)
    }
}
.buttonStyle(.bordered)

Behavior

Under the hood, the button handles the full lifecycle:

1

On appear, fetches product info and intro offer eligibility concurrently.

2

Shows a ProgressView while loading, then the smart label.

3

On tap, calls Payo.purchase() and shows a "Processing..." spinner.

4

On success, calls onPurchase. On cancel, does nothing. On error, calls onError.

Styling. PayoPurchaseButton is a standard SwiftUI Button underneath, so all standard modifiers work: .buttonStyle(.borderedProminent), .tint(.blue), .font(.title3), .padding(), etc.

Set Up App Store Connect

When you're ready for production, create matching subscription products in App Store Connect. Your product IDs in App Store Connect must match the ones in your Payo.plist exactly.

1

Open App Store Connect and select your app. In the sidebar under Monetization, select Subscriptions.

2

In the Subscription Groups section, click Create to make a new group (e.g., "Premium"). A subscription group ties related plans together — users can only have one active subscription per group, and they can upgrade or downgrade between plans within the same group.

3

Inside your group, click Create in the Subscriptions section. Enter a Reference Name (internal only — e.g., "Pro Monthly"), then enter your Product ID exactly as it appears in your Payo.plist.

4

On the product page, select a Subscription Duration (e.g., 1 Month). Then scroll to Subscription Prices and click Add Subscription Price to set your price — Apple will auto-calculate pricing for all other countries.

5

Scroll to Localization and click Add Localization. Choose your language (e.g., English), enter a Display Name (e.g., "Pro Monthly") and a Description (e.g., "Unlimited access to all premium features"), then click Add. Apple requires at least one localization.

6

Repeat steps 3–5 for each plan you want to offer (e.g., monthly + annual). Make sure each uses a different Product ID.

7

(Optional) To add a free trial or intro price, open the product and scroll to Introductory Offers. Click +, choose the offer type (free trial, pay-as-you-go, or pay up front), set the duration, and save. Payo detects and displays these automatically.

No code changes needed. Once your products exist in App Store Connect, Payo loads them automatically. The same product IDs in your Payo.plist work for both local StoreKit testing and production.

Using Claude Code?

Copy the prompt below and paste it into your terminal. Claude Code will enter plan mode, ask you a few questions, then set up Payo in your project automatically.

Best results with Claude Opus. Tested and recommended with opus model.