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.
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")
])
]
)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.
Tip
SkipMarketplace works best for non-consumable one-time-product entitlements. For subscriptions with server-side management, consider using RevenueCat via the skip-revenue library.
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 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.
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.
A minimal SwiftUI paywall showing fetched products:
import SwiftUI
import 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 {
switch try await Marketplace.current.purchase(item: product) {
case .success(let transaction):
// Grant entitlement, then acknowledge — see "Acknowledging Purchases" above
try await Marketplace.current.finish(purchaseTransaction: transaction)
case .pending, .userCancelled, .unverified:
break
}
} catch {
self.error = "Purchase failed: \(error.localizedDescription)"
}
}
}Initiate a purchase. Returns a PurchaseResult describing the outcome — distinguishing a completed purchase from a user cancellation, a pending purchase, and (on iOS) an unverified transaction. Wraps Product.purchase(options:) on iOS and BillingClient.launchBillingFlow() on Android.
let product = try await Marketplace.current.fetchProducts(
for: ["premium_upgrade"],
subscription: false
).first!
switch try await Marketplace.current.purchase(item: product) {
case .success(let transaction):
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)
case .pending:
// The purchase is awaiting external action (e.g. parental approval, SCA, or a cash/voucher
// payment). Do NOT grant the entitlement yet — listen via getPurchaseTransactionUpdates() to
// be notified when the pending purchase resolves to a completed transaction.
break
case .userCancelled:
// The user dismissed the purchase sheet.
break
case .unverified(let transaction, let error):
// iOS only: the purchase completed but StoreKit could not verify the transaction signature.
// Decide whether to trust it (e.g. after your own server-side verification) before unlocking.
print("Unverified transaction \(transaction.id ?? "unknown"): \(error)")
break
}Note
Earlier versions returned an optional PurchaseTransaction? that mapped both cancellation and pending purchases to nil. purchase(item:offer:) now returns a PurchaseResult so these outcomes are distinguishable — in particular, a pending Android purchase is no longer reported as a completed one.
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 pricingPhases
if let offer = product.subscriptionOffers?.first {
if case .success(let transaction) = try await Marketplace.current.purchase(
item: product,
offer: offer
) {
await unlock(transaction)
try await Marketplace.current.finish(purchaseTransaction: transaction)
}
}Note
On iOS, introductory offers are applied automatically by StoreKit when eligible — do not pass them via the offer parameter. Promotional offers require a server-signed JWS signature and must be applied via purchaseOptions (not yet exposed through SkipMarketplace's cross-platform surface; drop down to item.product for now). Win-back offers (iOS 18+) and Android subscription offers are passed through offer:.
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.
Important
Every successful purchase must be finished by calling Marketplace.current.finish(purchaseTransaction:). On Android, failing to acknowledge a purchase within three days will cause Google Play to automatically refund the purchase and revoke the entitlement — even if your app has already granted it to the user.
See Google's documentation for the exact rule: Process purchases — Acknowledge a purchase
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.
@MainActor
func 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.
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:
@main
struct 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() }
}
}Important
This is the primary path for handling pending purchases on Android (parent approval, slow card processing, cash payments). A pending purchase causes purchase(item:) to return .pending; when it eventually resolves to PURCHASED, the result is delivered through getPurchaseTransactionUpdates() — not as a return value from the original purchase() call. See Handle pending transactions.
| 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.
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 interval
Marketplace.current.requestReview(period: .days(60))
// Custom predicate — only ask after the user has had a positive interaction
Marketplace.current.requestReview(period: Marketplace.ReviewRequestDelay(shouldCheckReview: {
return launchCount > 5 && hasCompletedOnboarding && lastTaskWasSuccess
}))The default period (31 days) reflects the platforms' guidance:
- Apple's
requestReviewis 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:
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.
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.installationSource
if source.isFirstPartyAppStore {
Marketplace.current.requestReview()
}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, returning a PurchaseResult. 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 |
| Modifier | Description |
|---|---|
.appUpdatePrompt(forcePrompt:) |
Prompt the user to update when a newer version is available. Uses .appStoreOverlay on iOS, In-App Updates on Android. |
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) |
The outcome of purchase(item:offer:).
| Case | Description |
|---|---|
.success(PurchaseTransaction) |
The purchase completed and the transaction was verified. Grant the entitlement, then call finish(purchaseTransaction:). |
.pending |
The purchase is awaiting external action (Ask to Buy / SCA approval, or a cash/voucher payment on Android). The final result arrives via getPurchaseTransactionUpdates() — do not grant the entitlement yet. Maps to Product.PurchaseResult.pending on iOS and Purchase.PurchaseState.PENDING on Android. |
.userCancelled |
The user dismissed the purchase sheet. |
.unverified(PurchaseTransaction, Error) |
iOS only. The purchase completed but StoreKit could not verify the transaction's JWS signature; decide whether to trust it. Never produced on Android, where verification is done server-side via purchaseToken. |
Marketplace operations throw a typed MarketplaceError rather than crashing (fatalError) or throwing an untyped exception. On Android, Play Billing response codes are mapped to these cases (unmapped codes surface as .storeError); on iOS, StoreKit throws its own errors directly. User cancellation and pending purchases are not errors — they are reported via PurchaseResult.
| Case | Description |
|---|---|
.billingUnavailable |
Billing is unavailable on this device or for the current account (BILLING_UNAVAILABLE). |
.serviceDisconnected |
The billing service is disconnected; the operation can usually be retried. |
.productUnavailable |
The requested product is not available (ITEM_UNAVAILABLE). |
.itemAlreadyOwned |
The item is already owned and cannot be purchased again (ITEM_ALREADY_OWNED). |
.networkError |
A network error occurred (NETWORK_ERROR / SERVICE_TIMEOUT). |
.noActiveActivity |
No Android Activity is available to present the purchase flow. |
.unsupportedOffer |
The supplied offer is not supported by purchase(item:offer:) — e.g. an iOS promotional offer (sign it server-side and apply via purchase options) or a non-subscription offer. |
.unsupportedPlatform |
The operation is not supported on the current platform. |
.storeError(code:reason:) |
An otherwise-unmapped store error carrying the platform response code and a diagnostic reason. |
MarketplaceError conforms to CustomStringConvertible (and LocalizedError on iOS), so "\(error)" and error.localizedDescription both yield a readable message.
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 |
| 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) |
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) |
| 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" |
| 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 |
| Factory | Description |
|---|---|
.default |
Once every 31 days |
.days(Int) |
Once every N days |
init(shouldCheckReview:) |
Custom predicate |
This project is a Swift Package Manager module that uses the Skip plugin to build the package for both iOS and Android.
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.
This software is licensed under the Mozilla Public License 2.0.