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.

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>

Define your products in App Store Connect and/or the Google Play Console.

import SkipMarketplace
// One-time purchases
let 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))")
}
}
}
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)")
}
}
}
}
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)
}
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)
}
}

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

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

Prompt the user to rate your app, with built-in throttling:

import SkipMarketplace
// Request a review at most once every 31 days
Marketplace.current.requestReview(period: .days(31))
// Use the default period (31 days)
Marketplace.current.requestReview()
// Custom review logic
Marketplace.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.

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)

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

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
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
PropertyDescription
id: StringProduct identifier
displayName: StringLocalized product name
productDescription: StringLocalized product description
displayPrice: String?Formatted price string
isSubscription: BoolWhether this is a subscription product
oneTimePurchaseOfferInfoOne-time purchase offers (nil for subscriptions)
subscriptionOffersSubscription offers (nil for one-time purchases)
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)
isAcknowledged: BoolWhether the transaction has been finished
isAutoRenewing: BoolWhether 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)
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)
PropertyDescription
id: String?Offer identifier
pricingPhases: [SubscriptionPricingPhase]Pricing phases in this offer
offerToken: String?Offer token for purchase (Android only)
PropertyDescription
price: DecimalNumeric price for this phase
displayPrice: StringFormatted price string
billingPeriod: String?ISO 8601 duration (e.g. “P1M”, “P1Y”)
billingCycleCount: IntNumber of cycles (0 = infinite)
recurrenceMode: String”INFINITE_RECURRING”, “FINITE_RECURRING”, or “NON_RECURRING”
CaseDescription
.appleAppStoreApple App Store
.googlePlayStoreGoogle Play Store
.testFlightTestFlight
.marketplace(bundleId:)Alternative marketplace (EU)
.webDirect web install
.other(String?)Other source (e.g. F-Droid, Amazon)
.unknownUnknown source
FactoryDescription
.defaultOnce every 31 days
.days(Int)Once every N days
init(shouldCheckReview:)Custom logic

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.