Start charging for your app without writing payment code. No server, no account, completely free.
See how much code Payo saves you.
// 15+ lines of boilerplate...
let products = try await Product.products(for: ids)
guard let product = products.first else { return }
let result = try await product.purchase()
switch result {
case .success(let verification):
let txn = try checkVerified(verification)
await txn.finish()
// manually update UI state...
// handle expiration tracking...
// observe transaction updates...
case .userCancelled: break
case .pending: break
@unknown default: break
}
PayoPurchaseButton("pro_monthly")
if Payo.hasAccess { /* unlocked */ }
Everything runs on the device. Nothing to host or maintain.
No sign-up, no monthly fees. Just add it to your project.
Your users' purchase data never leaves their device.
Tiny footprint. Won't slow down your app or bloat your build.
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.
Add the Payo SPM package to your Xcode project.
In Xcode, go to File > Add Package Dependencies...
Paste the Payo repository URL:
https://github.com/PayoSDK/payo-ios
Set the dependency rule to Up to Next Major Version and click Add Package.
Payo requires iOS 15+.
Download this file, then drag it into your Xcode project navigator. When prompted, make sure "Copy items if needed" is checked.
How would you like to test?
Download and drag into your Xcode project navigator. Make sure "Copy items if needed" is checked.
Go to Product > Scheme > Edit Scheme > Run > Options and select Products.storekit from the StoreKit Configuration dropdown.
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.
Create matching products in App Store Connect. Your product IDs must match exactly:
Open App Store Connect and select your app. In the sidebar under Monetization, select Subscriptions.
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.
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.
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.
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.
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.
(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.
Payo is configured and ready to go. How would you like to start building?
Tap a scenario to see documentation, code examples, and best practices.
Trigger a purchase flow for a subscription or one-time purchase. Payo handles the StoreKit transaction, verifies it, and updates access automatically.
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.
PurchaseInfo contains: productID, transactionID, purchaseDate, expirationDate (nil for lifetime), and originalTransactionID.
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)")
}
}
}
}
@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.
Gate features behind a subscription. Use the one-liner view modifier for the simplest approach, or check access manually for full control.
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.
// 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")
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()
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.
// 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
}
For custom UI that should react to access changes in real time, observe Payo.state directly.
struct ContentView: View {
@ObservedObject var billing = Payo.state
var body: some View {
if billing.hasAccess {
ProFeatureView()
} else {
PaywallView()
}
}
}
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.
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.
Call this when building your paywall or pricing screen. Never hard-code prices — the App Store provides localized pricing that varies by country.
// 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)
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()
}
}
}
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.
Check if the user qualifies for an introductory offer (free trial, discounted first period, or pay-up-front) and display it on your paywall.
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.
// 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
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()
}
}
}
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.
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.
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.
Button("Restore Purchases") {
Task {
do {
try await Payo.restorePurchases()
// Access is automatically updated
} catch {
showAlert("Restore failed: \(error.localizedDescription)")
}
}
}
@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.
Open Apple's built-in subscription management sheet where users can upgrade, downgrade, or cancel their subscription.
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.
Button("Manage Subscription") {
Task {
try? await Payo.showManageSubscriptions()
}
}
@IBAction func manageTapped(_ sender: Any) {
Task {
try? await Payo.showManageSubscriptions()
}
}
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.
Button("Request Refund") {
Task {
let status = try? await Payo.beginRefundRequest("pro_monthly")
if status == .success {
// Apple is reviewing the request
}
}
}
@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.
Support multiple subscription tiers (e.g. "Pro" and "Premium") by configuring with named groups and checking access per group.
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.
struct FeatureView: View {
var body: some View {
VStack {
if Payo.hasAccess("pro") {
ProFeaturesView()
}
if Payo.hasAccess("premium") {
PremiumFeaturesView()
}
if !Payo.hasAccess {
PaywallView()
}
}
}
}
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.
Manually re-check the user's entitlements or clear all billing state.
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.
// Re-check purchases (e.g., after testing in Xcode Sandbox)
await Payo.refreshEntitlements()
Clears all billing state, stops the transaction observer, and re-initializes from Payo.plist. Useful when a user logs out of your app.
Button("Log Out") {
Task {
await Payo.reset()
// Automatically re-initializes from Payo.plist
}
}
@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.
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.
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.
// Smart label — auto-fetches price + intro offer
// Renders: "Start 7-Day Free Trial" or "Subscribe — $4.99"
PayoPurchaseButton("pro_monthly")
.buttonStyle(.borderedProminent)
Handle success and error events with optional closures. The button silently ignores user cancellation.
PayoPurchaseButton("pro_monthly", onPurchase: { info in
print("Purchased! Expires: \(info.expirationDate ?? .now)")
}, onError: { error in
print("Failed: \(error.localizedDescription)")
})
.buttonStyle(.borderedProminent)
Use a ViewBuilder closure to build any label you want. Your closure receives the loaded ProductInfo and a Bool indicating intro offer eligibility.
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)
Under the hood, the button handles the full lifecycle:
On appear, fetches product info and intro offer eligibility concurrently.
Shows a ProgressView while loading, then the smart label.
On tap, calls Payo.purchase() and shows a "Processing..." spinner.
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.
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.
Open App Store Connect and select your app. In the sidebar under Monetization, select Subscriptions.
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.
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.
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.
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.
Repeat steps 3–5 for each plan you want to offer (e.g., monthly + annual). Make sure each uses a different Product ID.
(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.