Marketplace
In-app purchases, subscriptions, app reviews, and update prompts for Skip apps on both iOS and Android.
On iOS this wraps Apple’s StoreKit ↗ framework. On Android it uses the Google Play Billing Library ↗ and Play Core ↗ libraries. The cross-platform surface mostly conforms to the OpenIAP ↗ specification.
Contents
Section titled “Contents”Add the dependency to your Package.swift file:
let package = Package( name: "my-package", products: [ .library(name: "MyProduct", targets: ["MyTarget"]), ], dependencies: [ .package(url: "https://source.skip.dev/skip-marketplace.git", "0.0.0"..<"2.0.0"), ], targets: [ .target(name: "MyTarget", dependencies: [ .product(name: "SkipMarketplace", package: "skip-marketplace") ]) ])Android Configuration
Section titled “Android Configuration”Add the billing permission to your AndroidManifest.xml:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <uses-permission android:name="com.android.vending.BILLING"/></manifest>The Google Play Billing Library ↗, In-App Review ↗, and In-App Updates ↗ Gradle dependencies are added automatically by SkipMarketplace’s skip.yml.
In-App Purchases
Section titled “In-App Purchases”Configure Products
Section titled “Configure Products”Define your products in:
- App Store Connect: Create consumable or non-consumable in-app purchases ↗, Create subscriptions ↗
- Google Play Console: Create one-time products ↗, Create subscriptions ↗
Use the same product identifiers across both stores so a single call to fetchProducts(for:subscription:) returns the appropriate product for the platform the app is running on.
Fetch Products and Prices
Section titled “Fetch Products and Prices”Fetch one or more one-time-purchase products by their identifier. Wraps Product.products(for:) ↗ on iOS and BillingClient.queryProductDetails() ↗ on Android.
import SkipMarketplace
let products = try await Marketplace.current.fetchProducts( for: ["premium_upgrade", "remove_ads"], subscription: false)
for product in products { print("\(product.displayName): \(product.displayPrice ?? "")") print(" ID: \(product.id)") print(" Description: \(product.productDescription)") print(" Is subscription: \(product.isSubscription)")
if let offers = product.oneTimePurchaseOfferInfo { for offer in offers { print(" Offer price: \(offer.displayPrice) (raw: \(offer.price))") if let discount = offer.discountPercentage { print(" Discount: \(discount)%") } } }}Identifiers that don’t correspond to a real product are silently omitted from the result — always check what was actually returned before assuming a product exists. See Product.products(for:) ↗ for Apple’s behavior and QueryProductDetailsResult ↗ for Google’s.
Fetch Subscription Products
Section titled “Fetch Subscription Products”For subscriptions, pass subscription: true. This corresponds to Product.SubscriptionInfo ↗ on iOS and ProductDetails.SubscriptionOfferDetails ↗ on Android.
let subscriptions = try await Marketplace.current.fetchProducts( for: ["sub_monthly", "sub_annual"], subscription: true)
for product in subscriptions { print("\(product.displayName): \(product.displayPrice ?? "")")
// Each subscription product can have multiple offers (introductory, promotional, win-back). // Each offer can have multiple pricing phases (free trial → discounted intro → standard). if let offers = product.subscriptionOffers { for offer in offers { print(" Offer ID: \(offer.id ?? "base")") for phase in offer.pricingPhases { print(" Phase: \(phase.displayPrice)") print(" Period: \(phase.billingPeriod ?? "unknown")") // ISO 8601 duration print(" Cycles: \(phase.billingCycleCount)") // 0 = infinite print(" Mode: \(phase.recurrenceMode)") // INFINITE_RECURRING / FINITE_RECURRING / NON_RECURRING } } }}The billingPeriod is an ISO 8601 duration string ↗ (e.g. P1M for one month, P1Y for one year, P7D for a seven-day trial), normalized across platforms.
Display Products in a Paywall
Section titled “Display Products in a Paywall”A minimal SwiftUI paywall showing fetched products:
import SwiftUIimport SkipMarketplace
struct PaywallView: View { @State private var products: [ProductInfo] = [] @State private var error: String? @State private var purchasing: String?
var body: some View { List { if let error { Text(error).foregroundStyle(.red) } ForEach(products, id: \.id) { product in Button { Task { await buy(product) } } label: { HStack { VStack(alignment: .leading) { Text(product.displayName).font(.headline) Text(product.productDescription).font(.caption) } Spacer() if purchasing == product.id { ProgressView() } else { Text(product.displayPrice ?? "—") } } } .disabled(purchasing != nil) } } .task { await load() } }
func load() async { do { products = try await Marketplace.current.fetchProducts( for: ["premium_upgrade", "remove_ads"], subscription: false ) } catch { self.error = "Failed to load products: \(error.localizedDescription)" } }
func buy(_ product: ProductInfo) async { purchasing = product.id defer { purchasing = nil } do { if let transaction = try await Marketplace.current.purchase(item: product) { // Grant entitlement, then acknowledge — see "Acknowledging Purchases" above try await Marketplace.current.finish(purchaseTransaction: transaction) } } catch { self.error = "Purchase failed: \(error.localizedDescription)" } }}Purchase a Product
Section titled “Purchase a Product”Initiate a purchase. Returns the resulting PurchaseTransaction, or nil if the user cancelled or the purchase is pending. Wraps Product.purchase(options:) ↗ on iOS and BillingClient.launchBillingFlow() ↗ on Android.
let product = try await Marketplace.current.fetchProducts( for: ["premium_upgrade"], subscription: false).first!
if let transaction = try await Marketplace.current.purchase(item: product) { print("Purchased: \(transaction.products)") print("Order ID: \(transaction.id ?? "unknown")") print("Date: \(transaction.purchaseDate)") print("Quantity: \(transaction.quantity)")
// Grant the entitlement to the user here. await unlock(transaction)
// Always finish the transaction. // On Android, Google Play will auto-refund within 3 days if this is skipped. try await Marketplace.current.finish(purchaseTransaction: transaction)} else { // nil = user cancelled, or the purchase is in a pending state (e.g. parental approval). // Listen via getPurchaseTransactionUpdates() to be notified when a pending purchase resolves.}Purchase with an Offer
Section titled “Purchase with an Offer”Subscription products may have introductory, promotional, or win-back offers ↗. On Android, subscription offers ↗ require an offer token — SkipMarketplace handles that automatically when you pass the offer.
let product = try await Marketplace.current.fetchProducts( for: ["sub_annual"], subscription: true).first!
// Pick the first subscription offer; in production, choose based on offer.id and pricingPhasesif let offer = product.subscriptionOffers?.first { if let transaction = try await Marketplace.current.purchase( item: product, offer: offer ) { await unlock(transaction) try await Marketplace.current.finish(purchaseTransaction: transaction) }}Finish (Acknowledge) Transactions
Section titled “Finish (Acknowledge) Transactions”finish(purchaseTransaction:) performs the platform-appropriate acknowledgement:
try await Marketplace.current.finish(purchaseTransaction: transaction)| Platform | What happens |
|---|---|
| iOS | Calls Transaction.finish() ↗, removing the transaction from the unfinished queue. |
| Android | Calls BillingClient.acknowledgePurchase() ↗ with the purchase’s token. Required within 3 days — see Google’s documented rule ↗. |
For consumable products on Android (e.g., in-game currency packs), you should call BillingClient.consumeAsync() ↗ instead of acknowledgePurchase(). SkipMarketplace’s finish() always acknowledges, so if you need consumption semantics drop down to the underlying transaction.purchaseTransaction (a com.android.billingclient.api.Purchase) and call consumeAsync directly inside a #if SKIP block.
See Google’s documentation for the exact rule: Process purchases — Acknowledge a purchase ↗
Query Entitlements
Section titled “Query Entitlements”Check what the user currently owns. Wraps Transaction.currentEntitlements ↗ on iOS and BillingClient.queryPurchasesAsync() ↗ on Android (called twice — once for INAPP and once for SUBS).
This is what you should call on app launch to restore purchases — there’s no separate “restore” API needed.
@MainActorfunc restorePurchases() async throws { let entitlements = try await Marketplace.current.fetchEntitlements() for transaction in entitlements { print("Owns: \(transaction.products)") print(" Purchased: \(transaction.purchaseDate)") print(" Acknowledged: \(transaction.isAcknowledged)") print(" Auto-renews: \(transaction.isAutoRenewing)") if let expiration = transaction.expirationDate { print(" Expires: \(expiration)") // subscriptions only, iOS only } if let revoked = transaction.revocationDate { print(" Revoked: \(revoked)") // iOS only — refunded or family-shared revoke continue }
// Apply the entitlement await unlock(transaction)
// If the transaction wasn't already acknowledged (e.g. from a prior session // that crashed before finishing), acknowledge it now to avoid auto-refund. if !transaction.isAcknowledged { try await Marketplace.current.finish(purchaseTransaction: transaction) } }}The isAcknowledged property reflects Purchase.isAcknowledged() ↗ on Android. On iOS, transactions returned from currentEntitlements are already verified, so this is always true.
Listen for Transaction Updates
Section titled “Listen for Transaction Updates”Observe purchase and subscription state changes in real time. Wraps Transaction.updates ↗ on iOS and PurchasesUpdatedListener ↗ on Android.
This catches:
- Pending purchases that resolve later (e.g. SCA approval ↗, Ask to Buy ↗).
- Subscription renewals that happen while the app is running.
- Out-of-band purchases initiated outside your app (e.g. App Store promoted IAPs).
Start the listener early in your app’s lifecycle, before any UI that depends on purchases:
@mainstruct MyApp: App { init() { Task { for try await transaction in Marketplace.current.getPurchaseTransactionUpdates() { print("Transaction update: \(transaction.products)") await applyEntitlement(transaction) try? await Marketplace.current.finish(purchaseTransaction: transaction) } } }
var body: some Scene { WindowGroup { ContentView() } }}Testing Purchases
Section titled “Testing Purchases”| Platform | Documentation |
|---|---|
| iOS | Setting up StoreKit testing in Xcode ↗ — local StoreKit configuration file, no App Store Connect required. Also: Sandbox accounts ↗. |
| Android | Test your Google Play Billing Library integration ↗ — license testers, static response product IDs ↗ (android.test.purchased, android.test.canceled, etc.). |
For end-to-end Android testing, the app must be installed from a track (internal/closed/open testing) of the same package name that holds the configured products in the Play Console. Sideloaded debug builds will return BILLING_UNAVAILABLE.
App Review Requests
Section titled “App Review Requests”Prompt the user to rate your app, with built-in throttling. Wraps AppStore.requestReview(in:) ↗ on iOS and the In-App Review API ↗ on Android.
import SkipMarketplace
// Request a review at most once every 31 days (default)Marketplace.current.requestReview()
// Custom intervalMarketplace.current.requestReview(period: .days(60))
// Custom predicate — only ask after the user has had a positive interactionMarketplace.current.requestReview(period: Marketplace.ReviewRequestDelay(shouldCheckReview: { return launchCount > 5 && hasCompletedOnboarding && lastTaskWasSuccess}))The default period (31 days) reflects the platforms’ guidance:
- Apple’s
requestReview↗ is limited to 3 prompts per 365-day window, and the system may not actually display a prompt every time. - Google’s In-App Review quotas ↗ similarly throttle requests internally; calling the API too often is a no-op.
In short: the OS already throttles you. SkipMarketplace’s period is a hint about when your code should bother making the call; the OS still has the final say.
For UX guidance on when to ask, see:
App Update Prompts
Section titled “App Update Prompts”Automatically prompt users when a newer version is available:
import SkipMarketplace
struct ContentView: View { var body: some View { YourViewCode() .appUpdatePrompt() }}The prompt fires when the view becomes active (via scenePhase), is throttled to once per 24 hours by default, and behaves differently per platform:
| Platform | Mechanism |
|---|---|
| iOS | Queries https://itunes.apple.com/lookup?bundleId=... for the latest published version, compares it to CFBundleShortVersionString, and presents an .appStoreOverlay() ↗ with an SKOverlay.AppConfiguration ↗ if a newer version exists. Respects the store’s minimumOsVersion. |
| Android | Uses the In-App Updates Library ↗ to launch an IMMEDIATE ↗ (fullscreen, blocking) update flow when UpdateAvailability.UPDATE_AVAILABLE ↗ is reported. Falls back to opening the Play Store listing if the in-app flow fails. |
Bypass the 24-hour throttle when you need to force the prompt (e.g., a mandatory update):
.appUpdatePrompt(forcePrompt: true)To test the Android in-app update flow, follow Google’s Test in-app updates ↗ guide — IMMEDIATE requires an internal-test-track install with a higher version code published.
Installation Source
Section titled “Installation Source”Determine where the app was installed from. On iOS this uses AppDistributor.current ↗ (iOS 17.4+, returns .unknown on earlier versions). On Android it uses PackageManager.getInstallSourceInfo() ↗ (Android 11+) or the deprecated getInstallerPackageName() on older versions.
switch await Marketplace.current.installationSource {case .appleAppStore: print("Installed from Apple App Store")case .googlePlayStore: print("Installed from Google Play Store")case .testFlight: print("Installed from TestFlight")case .marketplace(let bundleId): print("Installed from EU alternative marketplace: \(bundleId)")case .web: print("Installed via Web Distribution (EU)")case .other(let name): // e.g. "org.fdroid.fdroid" (F-Droid), "com.amazon.venezia" (Amazon Appstore), // "com.rileytestut.AltStore" (AltStore), "com.sec.android.app.samsungapps" (Galaxy Store) print("Installed from: \(name ?? "unknown")")case .unknown: print("Installation source unknown (sideload, debug build, or pre-Android 11 missing data)")}The convenience property isFirstPartyAppStore returns true only for .appleAppStore and .googlePlayStore — useful for gating features like in-app reviews or IAPs that only make sense on official stores:
let source = await Marketplace.current.installationSourceif source.isFirstPartyAppStore { Marketplace.current.requestReview()}API Reference
Section titled “API Reference”Marketplace
Section titled “Marketplace”The main entry point, accessed via Marketplace.current.
| Method / Property | Description |
|---|---|
current | The singleton marketplace instance |
installationSource | Where the app was installed from (async) |
fetchProducts(for:subscription:) | Fetch product details by ID. Wraps Product.products(for:) ↗ / BillingClient.queryProductDetails ↗ |
purchase(item:offer:) | Initiate a purchase. Wraps Product.purchase(options:) ↗ / BillingClient.launchBillingFlow ↗ |
fetchEntitlements() | Get all current entitlements. Wraps Transaction.currentEntitlements ↗ / BillingClient.queryPurchasesAsync ↗ |
finish(purchaseTransaction:) | Acknowledge/finish a transaction. Wraps Transaction.finish() ↗ / BillingClient.acknowledgePurchase ↗. Must be called within 3 days on Android — see why ↗. |
getPurchaseTransactionUpdates() | AsyncThrowingStream of transaction updates. Wraps Transaction.updates ↗ / PurchasesUpdatedListener ↗ |
requestReview(period:) | Request an app store review. Wraps AppStore.requestReview(in:) ↗ / ReviewManager.launchReviewFlow ↗ |
View Modifier
Section titled “View Modifier”| Modifier | Description |
|---|---|
.appUpdatePrompt(forcePrompt:) | Prompt the user to update when a newer version is available. Uses .appStoreOverlay ↗ on iOS, In-App Updates ↗ on Android. |
ProductInfo
Section titled “ProductInfo”Wraps StoreKit.Product ↗ on iOS / ProductDetails ↗ on Android. Access the underlying platform object via product.product for platform-specific functionality.
| Property | Description |
|---|---|
id: String | Product identifier |
displayName: String | Localized product name |
productDescription: String | Localized product description |
displayPrice: String? | Formatted price string for the default offer |
isSubscription: Bool | Whether this is a subscription product |
oneTimePurchaseOfferInfo: [OneTimePurchaseOfferInfo]? | One-time purchase offers (nil for subscriptions) |
subscriptionOffers: [SubscriptionOfferInfo]? | Subscription offers (nil for one-time purchases) |
PurchaseTransaction
Section titled “PurchaseTransaction”Wraps StoreKit.Transaction ↗ on iOS / com.android.billingclient.api.Purchase ↗ on Android. Access the underlying platform object via transaction.purchaseTransaction for platform-specific details.
| Property | Description |
|---|---|
id: String? | Order/transaction ID |
products: [String] | Purchased product identifiers |
purchaseDate: Date | When the purchase was made |
quantity: Int | Number of items purchased |
expirationDate: Date? | Subscription expiration (iOS only — Android subscriptions require server verification) |
isAcknowledged: Bool | Whether the transaction has been finished. Maps to Purchase.isAcknowledged() ↗ on Android; always true on iOS for entitlements from currentEntitlements |
isAutoRenewing: Bool | Whether a subscription auto-renews |
revocationDate: Date? | When the purchase was revoked (iOS only) |
originalID: String? | Original transaction ID (for renewals) |
purchaseToken: String? | Token for server-side verification ↗ (Android only). Use this with the Google Play Developer API ↗ purchases.products.get / purchases.subscriptions.get to verify on your backend. iOS uses JWS signed transactions ↗ instead. |
purchaseTransaction | The underlying platform transaction object |
OneTimePurchaseOfferInfo
Section titled “OneTimePurchaseOfferInfo”| Property | Description |
|---|---|
id: String? | Offer identifier |
price: Decimal | Numeric price |
displayPrice: String | Formatted price string |
fullPrice: Decimal? | Original price before discount (Android only) |
discountAmount: Decimal? | Discount value (Android only) |
discountDisplayAmount: String? | Formatted discount (Android only) |
discountPercentage: Int? | Discount percentage (Android only) |
SubscriptionOfferInfo
Section titled “SubscriptionOfferInfo”Wraps Product.SubscriptionOffer ↗ on iOS / ProductDetails.SubscriptionOfferDetails ↗ on Android.
| Property | Description |
|---|---|
id: String? | Offer identifier |
pricingPhases: [SubscriptionPricingPhase] | Pricing phases in this offer |
offerToken: String? | Offer token used by the billing flow (Android only) |
type | Offer type — .introductory, .promotional, .winBack (iOS only) |
SubscriptionPricingPhase
Section titled “SubscriptionPricingPhase”| Property | Description |
|---|---|
price: Decimal | Numeric price for this phase |
displayPrice: String | Formatted price string |
billingPeriod: String? | ISO 8601 duration (e.g. P1M, P1Y, P7D) |
billingCycleCount: Int | Number of cycles (0 = infinite) |
recurrenceMode: String | "INFINITE_RECURRING", "FINITE_RECURRING", or "NON_RECURRING" |
InstallationSource
Section titled “InstallationSource”| Case | Description |
|---|---|
.appleAppStore | Apple App Store |
.googlePlayStore | Google Play Store |
.testFlight | TestFlight beta |
.marketplace(bundleId:) | EU alternative marketplace (MarketplaceKit ↗) |
.web | EU Web Distribution |
.other(String?) | Other installer (e.g. F-Droid, Amazon, Galaxy Store, AltStore) |
.unknown | Unknown / sideloaded |
.isFirstPartyAppStore | true only for .appleAppStore and .googlePlayStore |
ReviewRequestDelay
Section titled “ReviewRequestDelay”| Factory | Description |
|---|---|
.default | Once every 31 days |
.days(Int) | Once every N days |
init(shouldCheckReview:) | Custom predicate |
Building
Section titled “Building”This project is a Swift Package Manager module that uses the Skip plugin to build the package for both iOS and Android.
Testing
Section titled “Testing”The module can be tested using the standard swift test command
or by running the test target for the macOS destination in Xcode,
which will run the Swift tests as well as the transpiled
Kotlin JUnit tests in the Robolectric Android simulation environment.
Parity testing can be performed with skip test,
which will output a table of the test results for both platforms.