Skip to main content

Adding AppCoins alongside StoreKit 1

This guide covers adding AppCoins billing to an app that already uses StoreKit 1. The steps differ depending on your integration strategy — read the Integration Strategies section before starting.

Overview

StoreKit 1 is built around callbacks and delegates: you request products through SKProductsRequest, listen for the response in SKProductsRequestDelegate, queue payments through SKPaymentQueue, and handle every transaction state change in SKPaymentTransactionObserver. You must also manage the observer lifecycle manually and explicitly call finishTransaction at the right moment.

AppCoins supports consumable in-app purchases only. Subscriptions and non-consumable products cannot be sold on Aptoide-distributed builds — AppCoins does not support them, and StoreKit billing is not available on alternative marketplace builds. Those product types will continue to work on App Store builds as normal.

The AppCoins SDK activates based on the iOS version and how the app was distributed — see How Runtime Detection Works.

The AppCoins SDK uses the same three concepts for the AppCoins billing path:

  • Product — fetch and purchase products with async throws methods.
  • Transaction — observe and query completed transactions using AsyncStream.
  • VerificationResult<Transaction> — the SDK verifies every transaction locally and wraps the result.

There is no observer to register, no delegate to conform to, and no SKPaymentQueue to interact with on the AppCoins side.

Integration strategies

There are two ways to ship both billing paths. See the integration strategies guide to decide which fits your setup.

Single build: Both billing paths live in the same binary. At runtime, call await AppcSDK.isAvailable() — if true, use the AppCoins path; if false, fall through to your existing StoreKit 1 code.

Separate builds: Keep your main branch exactly as-is (StoreKit 1 untouched). On an aptoide branch, replace the billing implementation files with AppCoins equivalents. The App Store binary never contains AppCoins code.

1. Setup

Add the AppCoins SDK Swift Package

In Xcode, add the Swift Package from:

https://github.com/Catappult/appcoins-sdk-ios.git

When prompted for a version rule, select Up to Next Major Version starting from the latest major version (e.g. 5.0.0). This ensures you automatically receive patch and minor updates while avoiding breaking changes from a future major release.

Xcode Configuration

Three configuration steps are required:

  1. Keychain Sharing

    1. Select your project in the navigator and choose your target under TARGETS.
    2. Go to the Signing & Capabilities tab and click + to add a capability.
    3. Search for Keychain Sharing and enable it.
    4. Replace the auto-populated value in Keychain Groups with com.aptoide.appcoins-wallet.
  2. URL Scheme

    1. Navigate to your target's Info tab.
    2. Under URL Types, click + and set the URL Scheme to $(PRODUCT_BUNDLE_IDENTIFIER).iap with role Editor.
  3. MKSellsDigitalGoods

    1. In the Info tab, scroll to Custom iOS Target Properties and click +.
    2. Add the key MKSellsDigitalGoods and set its value to YES (Boolean).

2. Initializing the SDK

Initialize the SDK at every entry point of your application. Add the following calls to SceneDelegate.swift or AppDelegate.swift.

SceneDelegate.swift:

import AppCoinsSDK

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
AppcSDK.initialize()
initialize() // your app setup
if AppcSDK.handle(redirectURL: connectionOptions.urlContexts.first?.url) { return }
}

func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
AppcSDK.initialize()
if AppcSDK.handle(redirectURL: URLContexts.first?.url) { return }
initialize()
}

AppDelegate.swift:

import AppCoinsSDK

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
AppcSDK.initialize()
initialize()
if let url = launchOptions?[.url] as? URL {
if AppcSDK.handle(redirectURL: url) { return true }
}
return true
}

func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
AppcSDK.initialize()
if AppcSDK.handle(redirectURL: url) { return true }
initialize()
return true
}

3. Single build: both billing paths in one binary

Use AppcSDK.isAvailable() to route between billing paths at runtime. Your existing StoreKit 1 code is unchanged — the guard simply bypasses it on Aptoide installs.

func purchase(sku: String) async {
if await AppcSDK.isAvailable() {
// AppCoins billing path (Aptoide installs)
guard let product = try? await Product.products(for: [sku]).first else { return }
let result = try? await product.purchase()
// ... handle AppCoins result
} else {
// StoreKit 1 path (App Store installs)
let payment = SKPayment(product: skProduct)
SKPaymentQueue.default().add(payment)
// ... your existing SK1 observer handles the result
}
}
ℹ️
AppcSDK.isAvailable() returns true on qualifying installs (see How Runtime Detection Works). On all other devices it returns false and your StoreKit 1 code runs as normal.

4. Product Fetching

The sections below show what the AppCoins SDK equivalent looks like for each StoreKit 1 operation.

StoreKit 1AppCoins SDK
SKProductsRequest + SKProductsRequestDelegatetry await Product.products(for:)

StoreKit 1:

var products: [SKProduct] = []

func requestProducts() {
let identifiers: Set<String> = ["gas", "turbo"]
let request = SKProductsRequest(productIdentifiers: identifiers)
request.delegate = self
request.start()
}

func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
products = response.products
}

func request(_ request: SKRequest, didFailWithError error: Error) {
print("Product request failed: \(error)")
}

AppCoins SDK equivalent:

import AppCoinsSDK

var products: [Product] = []

func requestProducts() async {
do {
products = try await Product.products(for: ["gas", "turbo"])
} catch {
print("Product request failed: \(error)")
}
}

5. Purchasing a Product

StoreKit 1AppCoins SDK
SKPaymentQueue.default().add(SKPayment(product:))try await product.purchase(options:)

StoreKit 1:

func buy(_ product: SKProduct) {
let payment = SKPayment(product: product)
SKPaymentQueue.default().add(payment)
}

// Then handle the result in paymentQueue(_:updatedTransactions:) — see Section 6

AppCoins SDK equivalent:

func buy(_ product: Product) async {
do {
let result = try await product.purchase()
switch result {
case .success(let verificationResult):
switch verificationResult {
case .verified(let transaction):
// Grant the item to the user
giveItem(for: transaction.productID)
await transaction.finish()
case .unverified(let transaction, let error):
// Decide based on your business logic
print("Unverified: \(error)")
}
case .pending:
break // Transaction is awaiting approval
case .userCancelled:
break
}
} catch let error as AppCoinsSDKError {
print("Purchase failed: \(error)")
}
}

6. Transaction Observer

StoreKit 1 requires registering an SKPaymentTransactionObserver to receive purchase results. The AppCoins SDK has no observer — the result is returned directly from product.purchase(). There is nothing to register.

StoreKit 1:

// AppDelegate / SceneDelegate
SKPaymentQueue.default().add(self)

extension YourClass: SKPaymentTransactionObserver {
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
for transaction in transactions {
switch transaction.transactionState {
case .purchased:
SKPaymentQueue.default().finishTransaction(transaction)
case .failed:
if let error = transaction.error { print(error) }
SKPaymentQueue.default().finishTransaction(transaction)
case .restored:
SKPaymentQueue.default().finishTransaction(transaction)
case .deferred, .purchasing:
break
@unknown default:
break
}
}
}
}

AppCoins SDK equivalent:

No observer needed. Handle the result where you call product.purchase():

import AppCoinsSDK

func buy(_ product: Product) async {
do {
let result = try await product.purchase()
switch result {
case .success(let verificationResult):
if case .verified(let transaction) = verificationResult {
giveItem(for: transaction.productID)
await transaction.finish()
}
case .pending, .userCancelled:
break
}
} catch let error as AppCoinsSDKError {
print("Purchase failed: \(error)")
}
}

Recovery for purchases that did not complete (crash, force-quit) is handled by Transaction.unfinished on launch — see Section 7.

7. Unfinished Transactions on Launch

StoreKit 1 surfaces unfinished transactions automatically through the observer. In the AppCoins SDK, explicitly query Transaction.unfinished on every launch.

⚠️
Query and finish unfinished transactions every time your app launches. Users who paid during a previous session will not receive their items until transactions are finished. Purchases are automatically refunded after 24 hours if not consumed.

StoreKit 1:

// Transactions were replayed automatically to paymentQueue(_:updatedTransactions:) on every launch
SKPaymentQueue.default().add(self)

AppCoins SDK equivalent:

func processUnfinishedTransactions() async {
for await verificationResult in Transaction.unfinished {
if case .verified(let transaction) = verificationResult {
giveItem(for: transaction.productID)
await transaction.finish()
}
}
}

Call this during your app startup flow, after AppcSDK.initialize().

8. Restoring Purchases

StoreKit 1:

SKPaymentQueue.default().restoreCompletedTransactions()
// Results delivered to paymentQueue(_:updatedTransactions:) with state .restored

AppCoins SDK equivalent:

func restorePurchases() async {
for await verificationResult in Transaction.all {
if case .verified(let transaction) = verificationResult {
giveItem(for: transaction.productID)
await transaction.finish()
}
}
}
⚠️
AppCoins only supports consumable products. Transaction.all includes every transaction ever recorded for the user, including already-finished ones. Calling giveItem() on each of them will re-grant items that have already been consumed. If you need a restore flow, verify server-side that each item has not already been delivered before granting it again — or omit restore entirely for consumable-only apps.

9. Finishing Transactions

StoreKit 1AppCoins SDK
SKPaymentQueue.default().finishTransaction(transaction)await transaction.finish()

transaction.finish() in the AppCoins SDK does not throw. No do/catch is needed around it.

10. Key Differences

StoreKit 1AppCoins SDK
SKProduct.productIdentifierproduct.id
SKProduct.localizedTitleproduct.displayName
SKProduct.price (NSDecimalNumber)product.price (Decimal)
SKProduct.priceLocale + formatterproduct.displayPrice (pre-formatted String)
SKPayment / SKPaymentTransactionTransaction
paymentQueue(_:updatedTransactions:)product.purchase() return value + Transaction.unfinished on launch
SKPaymentQueue.default().finishTransactionawait transaction.finish()
transaction.transactionIdentifier (String?)transaction.id (String)
SKPaymentQueue.default().add(observer)No registration required
SKPaymentQueue.default().restoreCompletedTransactions()Transaction.all async stream