Skip to content
Skip
3k

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.

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.

Define your products in:

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 {
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)"
}
}
}

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.
}

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 let transaction = try await Marketplace.current.purchase(
item: product,
offer: offer
) {
await unlock(transaction)
try await Marketplace.current.finish(purchaseTransaction: transaction)
}
}

finish(purchaseTransaction:) performs the platform-appropriate acknowledgement:

try await Marketplace.current.finish(purchaseTransaction: transaction)
PlatformWhat happens
iOSCalls Transaction.finish(), removing the transaction from the unfinished queue.
AndroidCalls 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

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() }
}
}
PlatformDocumentation
iOSSetting up StoreKit testing in Xcode — local StoreKit configuration file, no App Store Connect required. Also: Sandbox accounts.
AndroidTest 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 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:

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:

PlatformMechanism
iOSQueries 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.
AndroidUses 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 / PropertyDescription
currentThe singleton marketplace instance
installationSourceWhere 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
ModifierDescription
.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.

PropertyDescription
id: StringProduct identifier
displayName: StringLocalized product name
productDescription: StringLocalized product description
displayPrice: String?Formatted price string for the default offer
isSubscription: BoolWhether this is a subscription product
oneTimePurchaseOfferInfo: [OneTimePurchaseOfferInfo]?One-time purchase offers (nil for subscriptions)
subscriptionOffers: [SubscriptionOfferInfo]?Subscription offers (nil for one-time purchases)

Wraps StoreKit.Transaction on iOS / com.android.billingclient.api.Purchase on Android. Access the underlying platform object via transaction.purchaseTransaction for platform-specific details.

PropertyDescription
id: String?Order/transaction ID
products: [String]Purchased product identifiers
purchaseDate: DateWhen the purchase was made
quantity: IntNumber of items purchased
expirationDate: Date?Subscription expiration (iOS only — Android subscriptions require server verification)
isAcknowledged: BoolWhether the transaction has been finished. Maps to Purchase.isAcknowledged() on Android; always true on iOS for entitlements from currentEntitlements
isAutoRenewing: BoolWhether 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.
purchaseTransactionThe underlying platform transaction object
PropertyDescription
id: String?Offer identifier
price: DecimalNumeric price
displayPrice: StringFormatted 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.

PropertyDescription
id: String?Offer identifier
pricingPhases: [SubscriptionPricingPhase]Pricing phases in this offer
offerToken: String?Offer token used by the billing flow (Android only)
typeOffer type — .introductory, .promotional, .winBack (iOS only)
PropertyDescription
price: DecimalNumeric price for this phase
displayPrice: StringFormatted price string
billingPeriod: String?ISO 8601 duration (e.g. P1M, P1Y, P7D)
billingCycleCount: IntNumber of cycles (0 = infinite)
recurrenceMode: String"INFINITE_RECURRING", "FINITE_RECURRING", or "NON_RECURRING"
CaseDescription
.appleAppStoreApple App Store
.googlePlayStoreGoogle Play Store
.testFlightTestFlight beta
.marketplace(bundleId:)EU alternative marketplace (MarketplaceKit)
.webEU Web Distribution
.other(String?)Other installer (e.g. F-Droid, Amazon, Galaxy Store, AltStore)
.unknownUnknown / sideloaded
.isFirstPartyAppStoretrue only for .appleAppStore and .googlePlayStore
FactoryDescription
.defaultOnce 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.