Skip to content

skiptools/skip-marketplace

Repository files navigation

SkipMarketplace

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

Setup

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

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

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.

Configure Products

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

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

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

Purchase a Product

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.

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

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

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.

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

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:

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

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

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:

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

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

API Reference

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, 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

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

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)

PurchaseResult

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.

MarketplaceError

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.

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

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

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

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

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

Factory Description
.default Once every 31 days
.days(Int) Once every N days
init(shouldCheckReview:) Custom predicate

Building

This project is a Swift Package Manager module that uses the Skip plugin to build the package for both iOS and Android.

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.

License

This software is licensed under the Mozilla Public License 2.0.

About

Interface to marketplace services such as the Apple App Store and Google Play Store

Resources

License

Contributing

Stars

3 stars

Watchers

0 watching

Forks

Sponsor this project

Contributors

Languages