Where do I find this?
In Xcode, select your app target → General tab → look for Bundle Identifier. It looks like com.yourcompany.yourapp.
You built the app. Now you want to get paid. Add subscriptions to your iOS app with one Swift package and a few lines of code.
A few lines of code and your app knows who's paid — always up to date, automatically.
This tells Payo which app these products belong to.
In Xcode, select your app target → General tab → look for Bundle Identifier. It looks like com.yourcompany.yourapp.
Tell Payo which products you're selling and what access they unlock.
You'll create these product IDs in App Store Connect in a later step. Just decide what they'll be called now (e.g. pro_monthly, pro_annual).
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+.
Add this to your app's launch point. That's it — no config files needed.
Payo.configure("generating...")
import payo @main struct MyApp: App { init() { Payo.configure("<your API key>") } var body: some Scene { WindowGroup { ContentView() } } }
Create your subscription products in App Store Connect so Payo can load them.
Open App Store Connect → Business (or Agreements, Tax, and Banking). Make sure the Paid Apps agreement status is Active. If it says "Action Needed", complete all sections: contact info, bank account, and tax forms. Products won't load — even in Sandbox — until this is signed.
Select your app, then 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 a Product ID of your choice (e.g. pro_monthly). This must exactly match what you'll use in your Swift code with Payo.
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 4–6 for each additional subscription plan you want to offer (e.g. monthly and annual). Make sure each uses a unique Product ID.
To add a free trial or intro price, open the product and scroll to Subscription Prices. Click + next to that section, select Create Introductory Offer, 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.
To test purchases on a real device or simulator, you need a free Sandbox Apple ID. This takes about 60 seconds.
Apple requires a separate Sandbox Apple ID to test in-app purchases. Your regular Apple ID won't work in sandbox mode. Sandbox purchases are always free — no real charges.
Payo is configured and ready to go. How would you like to start building?
Your dashboard is ready
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 product ID (for multi-tier apps)
GroupBox("Premium Features") {
PremiumDashboard()
}
.requiresAccess(group: "premium_monthly")
// Gate behind a tier (recommended for multi-tier)
GroupBox("Pro Features") {
ProDashboard()
}
.requiresTier("pro")
override func viewDidLoad() {
super.viewDidLoad()
// Gate a view — adds blur + lock overlay automatically
premiumView.setRequiresAccess()
// Gate behind a specific product ID
analyticsView.setRequiresAccess(group: "premium_monthly")
}
// 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 product ID (for multi-tier apps)
if Payo.hasAccess("premium_monthly") {
// 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
}
}
}
}
Eligibility is per subscription group. Apple allows one intro offer per subscription group. If all your products are in the same group (recommended), the no-argument version covers everything.
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.
Let users redeem promotional offer codes. Apple shows their native offer code sheet — no custom UI needed. Requires iOS 16+.
Button("Redeem Offer Code") {
Task {
try? await Payo.presentOfferCodeRedeemSheet()
}
}
Gate features behind named tiers instead of individual product IDs. A tier like "pro" maps to multiple products (e.g. pro_monthly and pro_annual), so you check once instead of listing every product.
If your app has different feature levels — for example, a "Pro" plan and a "Premium" plan — configure tiers in your Payo dashboard during setup and check them by name.
Returns true if any product mapped to this tier is active. For example, hasAccess("pro") returns true if the user owns pro_monthly or pro_annual.
struct FeatureView: View {
var body: some View {
VStack {
// Gate features behind a tier
// "pro" maps to pro_monthly + pro_annual in your dashboard
if Payo.hasAccess("pro") {
ProFeaturesView()
}
if Payo.hasAccess("premium") {
PremiumFeaturesView()
}
if !Payo.hasAccess {
PaywallView()
}
}
}
}
// Or use the one-liner view modifier
GroupBox("Pro Analytics") {
AnalyticsChart()
}
.requiresTier("pro")
class FeatureViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
if Payo.hasAccess("pro") {
setupProFeatures()
}
if Payo.hasAccess("premium") {
setupPremiumFeatures()
}
if !Payo.hasAccess {
showPaywall()
}
}
}
You can also check individual product IDs directly if you don't use tiers.
// Check a specific product ID
if Payo.hasAccess("pro_monthly") || Payo.hasAccess("pro_annual") {
// pro features
}
// Tier properties:
tier.id // "pro"
tier.productIDs // ["pro_monthly", "pro_annual"]
tier.isActive // true if any mapped product is owned
// List all tiers with their status
for tier in Payo.tiers {
print("\(tier.id): \(tier.isActive ? "active" : "inactive")")
}
// Reactive — updates automatically
@ObservedObject var billing = Payo.state
// billing.tiers — reactive [Tier] array
Tiers are configured from your Payo dashboard. The setup wizard creates them for you. They map a tier name (like "pro") to one or more product IDs. If you add a new product later (e.g. pro_weekly), just update the mapping — your app code stays the same.
Keep all tiers in one Apple subscription group. In App Store Connect, put all your plans (pro_monthly, pro_annual, premium_monthly, etc.) in the same subscription group. This way, Apple automatically handles upgrades and downgrades — a user can only have one active plan at a time.
Get detailed information about the user's current subscription — including their plan, renewal date, cancellation status, and billing issues. Essential for building settings screens.
Use this when building a settings or account screen that shows: "You're on Pro Monthly", "Renews March 15", "Your plan ends on March 15" (canceled), or "Payment issue — please update your card" (billing retry/grace period).
// SubscriptionInfo properties:
info.productID // "pro_monthly"
info.state // .subscribed, .expired, .inGracePeriod, .inBillingRetry, .revoked
info.expirationDate // Date? — when the subscription expires or renews
info.purchaseDate // Date — when originally purchased
info.willAutoRenew // Bool — false when the user has canceled
info.originalTransactionID // UInt64 — same across all renewals
Synchronous, works anywhere. Returns the set of all currently active product IDs. Use this when you just need to know which plan the user is on.
struct SettingsView: View {
@ObservedObject var billing = Payo.state
@State private var subInfo: SubscriptionInfo?
var body: some View {
List {
// Current plan (reactive)
Section("Current Plan") {
if let id = billing.activeProductIDs.first {
Text("Plan: \(id)")
} else {
Text("No active subscription")
}
}
// Subscription details
if let info = subInfo {
Section("Subscription Details") {
if let exp = info.expirationDate {
if info.willAutoRenew {
Text("Renews \(exp.formatted())")
} else {
Text("Ends \(exp.formatted())")
.foregroundStyle(.orange)
}
}
// Billing issue banner
if info.state == .inGracePeriod || info.state == .inBillingRetry {
Label("Payment issue — update your card", systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
}
}
}
// Actions
Section {
Button("Manage Subscription") {
Task { try? await Payo.showManageSubscriptions() }
}
Button("Restore Purchases") {
Task { try? await Payo.restorePurchases() }
}
}
}
.task {
subInfo = await Payo.subscriptionInfo()
}
}
}
class SettingsViewController: UIViewController {
@IBOutlet weak var planLabel: UILabel!
@IBOutlet weak var renewalLabel: UILabel!
@IBOutlet weak var billingBanner: UIView!
override func viewDidLoad() {
super.viewDidLoad()
Task {
// Show current plan
let activeIDs = Payo.activeProductIDs
planLabel.text = activeIDs.first ?? "No active plan"
// Show subscription details
if let info = await Payo.subscriptionInfo() {
if let exp = info.expirationDate {
renewalLabel.text = info.willAutoRenew
? "Renews \(exp.formatted())"
: "Ends \(exp.formatted())"
}
// Show billing issue banner
billingBanner.isHidden = (info.state != .inGracePeriod
&& info.state != .inBillingRetry)
}
}
}
}
Canceled ≠ expired. When a user cancels, willAutoRenew becomes false but state remains .subscribed until expirationDate. Show "Your plan ends on [date]" rather than removing access immediately.
Reactive active product IDs. Payo.state.activeProductIDs is a @Published set that updates automatically on purchase, restore, renewal, and expiry. Use it with @ObservedObject for SwiftUI views that need to show which plan is active.
Manually re-check the user's access state 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.refreshAccess()
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.
Present a fully custom paywall powered by your dashboard offerings. The .paywall() modifier handles offering fetching, product loading, and event tracking automatically.
The modifier fetches your current offering and its products, then passes them to your custom UI.
@State var showPaywall = false
var body: some View {
Button("Upgrade") { showPaywall = true }
.paywall(isPresented: $showPaywall) { offering, products in
VStack {
Text(offering.metadata["headline"] ?? "Go Pro")
.font(.largeTitle)
ForEach(products, id: \.id) { product in
PayoPurchaseButton(product.id) { info, isEligible in
HStack {
Text(info.displayName)
Spacer()
Text(info.displayPrice)
}
.padding()
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
}
}
}
}
}
When isPresented becomes true, the SDK fetches the current offering and loads its products from the App Store.
Your closure receives the Offering (with metadata) and [ProductInfo] (with localized prices). Build any UI you want.
The SDK automatically tracks paywall_presented and paywall_dismissed events. No manual event calls needed.
If a user dismisses your paywall, automatically show them a second chance offering — like a discounted price or a different pitch. Just set declineOfferingID.
.paywall(
isPresented: $showPaywall,
config: .init(declineOfferingID: "sale_50_off")
) { offering, products in
// Your paywall UI — same closure for both offerings.
// The offering changes automatically on decline.
YourPaywallView(offering: offering, products: products)
}
User sees your current offering first.
If they dismiss, the SDK immediately re-presents with the "sale_50_off" offering — different products, different metadata, same UI closure.
Both presentations are tracked separately, so you can see conversion rates for each offering in your dashboard.
Override which offering to show instead of using the current one. Useful for promo banners, deep links, or seasonal campaigns.
// Show a specific offering instead of the current one
.paywall(
isPresented: $showPaywall,
config: .init(offeringID: "black_friday")
) { offering, products in
YourPaywallView(offering: offering, products: products)
}
A/B experiments work automatically. If you have an active experiment, .paywall() will show the offering for the user's assigned variant — no extra code needed. Combined with declineOfferingID, you can test both which offering converts best and which decline offer recovers the most users.
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 → Business (or Agreements, Tax, and Banking). Make sure the Paid Apps agreement status is Active. If it says "Action Needed", complete all sections: contact info, bank account, and tax forms. Products won't load — even in Sandbox — until this is signed.
Select your app, then 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 4–6 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 Subscription Prices. Click + next to that section, select Create Introductory Offer, 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.