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 withasync throwsmethods.Transaction— observe and query completed transactions usingAsyncStream.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:
-
Keychain Sharing
- Select your project in the navigator and choose your target under TARGETS.
- Go to the Signing & Capabilities tab and click + to add a capability.
- Search for Keychain Sharing and enable it.
- Replace the auto-populated value in Keychain Groups with
com.aptoide.appcoins-wallet.
-
URL Scheme
- Navigate to your target's Info tab.
- Under URL Types, click + and set the URL Scheme to
$(PRODUCT_BUNDLE_IDENTIFIER).iapwith role Editor.
-
MKSellsDigitalGoods
- In the Info tab, scroll to Custom iOS Target Properties and click +.
- Add the key
MKSellsDigitalGoodsand set its value toYES(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 1 | AppCoins SDK |
|---|---|
SKProductsRequest + SKProductsRequestDelegate | try 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 1 | AppCoins 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.
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()
}
}
}
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 1 | AppCoins 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 1 | AppCoins SDK |
|---|---|
SKProduct.productIdentifier | product.id |
SKProduct.localizedTitle | product.displayName |
SKProduct.price (NSDecimalNumber) | product.price (Decimal) |
SKProduct.priceLocale + formatter | product.displayPrice (pre-formatted String) |
SKPayment / SKPaymentTransaction | Transaction |
paymentQueue(_:updatedTransactions:) | product.purchase() return value + Transaction.unfinished on launch |
SKPaymentQueue.default().finishTransaction | await transaction.finish() |
transaction.transactionIdentifier (String?) | transaction.id (String) |
SKPaymentQueue.default().add(observer) | No registration required |
SKPaymentQueue.default().restoreCompletedTransactions() | Transaction.all async stream |