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.
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>In-App Purchases
Section titled “In-App Purchases”Configure Products
Section titled “Configure Products”Define your products in App Store Connect ↗ and/or the Google Play Console ↗.
Fetch Products and Prices
Section titled “Fetch Products and Prices”import SkipMarketplace
// One-time purchaseslet products = try await Marketplace.current.fetchProducts( for: ["product1", "product2"], subscription: false)
for product in products { print("\(product.displayName): \(product.displayPrice ?? "")") print(" Description: \(product.productDescription)") print(" Is subscription: \(product.isSubscription)")
if let offers = product.oneTimePurchaseOfferInfo { for offer in offers { print(" Price: \(offer.displayPrice) (\(offer.price))") } }}Fetch Subscription Products
Section titled “Fetch Subscription Products”let subscriptions = try await Marketplace.current.fetchProducts( for: ["sub_monthly", "sub_annual"], subscription: true)
for product in subscriptions { print("\(product.displayName): \(product.displayPrice ?? "")")
if let offers = product.subscriptionOffers { for offer in offers { for phase in offer.pricingPhases { print(" Phase: \(phase.displayPrice)") print(" Period: \(phase.billingPeriod ?? "unknown")") print(" Cycles: \(phase.billingCycleCount)") print(" Mode: \(phase.recurrenceMode)") } } }}Purchase a Product
Section titled “Purchase a Product”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)")
// Always finish (acknowledge) the transaction try await Marketplace.current.finish(purchaseTransaction: transaction)}Purchase with an Offer
Section titled “Purchase with an Offer”let product = try await Marketplace.current.fetchProducts( for: ["premium_upgrade"], subscription: false).first!
if let offer = product.oneTimePurchaseOfferInfo?.first { if let transaction = try await Marketplace.current.purchase( item: product, offer: offer ) { try await Marketplace.current.finish(purchaseTransaction: transaction) }}Query Entitlements
Section titled “Query Entitlements”Check what the user currently owns:
let entitlements = try await Marketplace.current.fetchEntitlements()for transaction in entitlements { print("Owns: \(transaction.products)") print(" Purchased: \(transaction.purchaseDate)") print(" Acknowledged: \(transaction.isAcknowledged)") if let expiration = transaction.expirationDate { print(" Expires: \(expiration)") } // Finish each transaction to acknowledge receipt try await Marketplace.current.finish(purchaseTransaction: transaction)}Listen for Transaction Updates
Section titled “Listen for Transaction Updates”Observe purchase and subscription state changes in real time:
Task { for try await transaction in Marketplace.current.getPurchaseTransactionUpdates() { print("Transaction update: \(transaction.products)") try await Marketplace.current.finish(purchaseTransaction: transaction) }}Testing Purchases
Section titled “Testing Purchases”- iOS: Setting up StoreKit testing in Xcode ↗
- Android: Test your Google Play Billing Library integration ↗
App Review Requests
Section titled “App Review Requests”Prompt the user to rate your app, with built-in throttling:
import SkipMarketplace
// Request a review at most once every 31 daysMarketplace.current.requestReview(period: .days(31))
// Use the default period (31 days)Marketplace.current.requestReview()
// Custom review logicMarketplace.current.requestReview(period: Marketplace.ReviewRequestDelay(shouldCheckReview: { return launchCount > 5 && hasCompletedOnboarding}))For guidance on when to request reviews, see the documentation for the Apple App Store ↗ and Google Play Store ↗.
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() }}On iOS, this queries https://itunes.apple.com/lookup?bundleId=... for the latest version and presents an .appStoreOverlay() ↗ when an update is available.
On Android, this uses the Google Play In-App Updates Library ↗ to display a fullscreen “immediate” update flow. See Google’s guide to test in-app updates ↗.
The prompt is throttled to once per 24 hours by default. Use forcePrompt: true to bypass throttling:
.appUpdatePrompt(forcePrompt: true)Installation Source
Section titled “Installation Source”Determine where the app was installed from:
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 marketplace: \(bundleId)")case .web: print("Installed from the web")case .other(let name): print("Installed from: \(name ?? "unknown")")case .unknown: print("Installation source unknown")}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 |
purchase(item:offer:purchaseOptions:) | Initiate a purchase |
fetchEntitlements() | Get all current entitlements |
finish(purchaseTransaction:) | Acknowledge/finish a transaction |
getPurchaseTransactionUpdates() | Stream of transaction updates |
requestReview(period:) | Request an app store review |
ProductInfo
Section titled “ProductInfo”| Property | Description |
|---|---|
id: String | Product identifier |
displayName: String | Localized product name |
productDescription: String | Localized product description |
displayPrice: String? | Formatted price string |
isSubscription: Bool | Whether this is a subscription product |
oneTimePurchaseOfferInfo | One-time purchase offers (nil for subscriptions) |
subscriptionOffers | Subscription offers (nil for one-time purchases) |
PurchaseTransaction
Section titled “PurchaseTransaction”| 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) |
isAcknowledged: Bool | Whether the transaction has been finished |
isAutoRenewing: Bool | Whether a subscription auto-renews |
revocationDate: Date? | When the purchase was revoked, if at all (iOS only) |
originalID: String? | Original transaction ID (for renewals) |
purchaseToken: String? | Token for server verification (Android only) |
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”| Property | Description |
|---|---|
id: String? | Offer identifier |
pricingPhases: [SubscriptionPricingPhase] | Pricing phases in this offer |
offerToken: String? | Offer token for purchase (Android 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”) |
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 |
.marketplace(bundleId:) | Alternative marketplace (EU) |
.web | Direct web install |
.other(String?) | Other source (e.g. F-Droid, Amazon) |
.unknown | Unknown source |
ReviewRequestDelay
Section titled “ReviewRequestDelay”| Factory | Description |
|---|---|
.default | Once every 31 days |
.days(Int) | Once every N days |
init(shouldCheckReview:) | Custom logic |
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.