Supported on: iOS SDK
1.0.61+Configuration
Set the billing provider when configuring the SDK:BillingProvider Options
Who owns the transaction
Exactly one component finishes a StoreKit transaction. The SDK finishes transactions only when it owns billing (.storeKit2):
When a provider counts as linked into AppDNA. AppDNA’s CocoaPods pod and Swift package declare no RevenueCat or Adapty dependency. The SDK compiles its RevenueCat bridge only in a source or vendored build of AppDNA that adds RevenueCat AND defines the Swift compilation condition
APPDNA_LINK_REVENUECAT (Adapty: APPDNA_LINK_ADAPTY) on the AppDNASDK target itself — a condition set on your app’s target does not reach AppDNA. In a Swift Package Manager source build: uncomment the provider’s package in Package.swift, add its product (RevenueCat / Adapty) to the AppDNASDK target’s dependencies, and add swiftSettings: [.define("APPDNA_LINK_REVENUECAT")] to that target. In a CocoaPods source or vendored build: in your copy of AppDNASDK.podspec, add s.dependency 'RevenueCat' (Adapty: s.dependency 'Adapty') and s.pod_target_xcconfig = { 'SWIFT_ACTIVE_COMPILATION_CONDITIONS' => '$(inherited) APPDNA_LINK_REVENUECAT' }, which sets SWIFT_ACTIVE_COMPILATION_CONDITIONS on the AppDNASDK pod target. Adding RevenueCat or Adapty to your own app does not link it into AppDNA, on CocoaPods or on Swift Package Manager — on Swift Package Manager that used to depend on build order: an app that added purchases-ios as a package made the module visible inside AppDNA, and AppDNA bought through RevenueCat. Check at launch: without the bridge, configure logs “RevenueCat is not linked into this build — the SDK will not buy or restore; …” (“Adapty is not linked into this build — …” for Adapty), and a paywall plan tap reports providerNotAvailable.
Choose .revenueCat when RevenueCat is your purchase SDK. AppDNA then never finishes (iOS) or acknowledges (Android) a transaction, an SDK paywall tap (RevenueCat not linked into AppDNA — every published build) calls onPaywallPurchaseFailed(errorType: "providerNotAvailable"), and purchases reach AppDNA through the RevenueCat webhook. Do not also track purchase or subscription events yourself with AppDNA.track — use custom event names for UI analytics; the webhook is the purchase source.
Host recipe — an AppDNA paywall under a provider that owns purchases. A tap on a paywall plan shows no store sheet. The SDK emits purchase_failed with error_type: "providerNotAvailable" and calls onPaywallPurchaseFailed(paywallId:error:errorType:productId:) with errorType == "providerNotAvailable" and the tapped productId. Start the purchase with your provider from that callback, then dismiss the paywall. The failure route authored on the paywall (post_purchase.on_failure) also runs.
Adapty.activate(apiKey), restorePurchases, getProfile, AccessLevel.vendorProductId; the 2.x releases share these APIs but were not compiled against); entitlements and restores then report the product ids behind the profile’s active access levels.
Entitlements under .revenueCat / .adapty (provider not linked into AppDNA — every published build) are read from StoreKit (Transaction.currentEntitlements), not from the provider, and can differ from the provider’s entitlement mapping (for example promotional grants). Use the provider’s own entitlement API (RevenueCat CustomerInfo, Adapty’s profile access levels) as the source of truth.
Adapty: connect the Adapty webhook to add server-side renewal and refund data once its mapping is enabled; the SDK keeps sending device renewal / cancel events. See Inbound integrations.
The SDK sends
billing_owner on its purchase verification calls. An app with a connected RevenueCat or Adapty integration that still runs an older Android SDK on storeKit2 gets its Android verifications refused (409 provider_owned) until it updates. The iOS SDK verifies only under .storeKit2 and always sends billing_owner: "sdk".Module Access
Access the billing module through theAppDNA.billing property:
Get Products
Fetch product information from the App Store:ProductInfo
Purchase a Product
Initiate a purchase:options parameter accepts a PurchaseOptions value. Its appAccountToken is applied; its promotionalOffer is not — the SDK does not apply Apple promotional offers, and AppDNA does not sign them.
A custom appAccountToken: the SDK maps each purchase token to the signed-in user (one device-wide map, up to 500 entries, kept across reset() / identify) so a late purchase reaches its owner. A purchase whose token is not in the map (or made while signed out) is treated as untagged: it is reported, and delivered to a signed-out user or to the first user who signed in on the device.
TransactionInfo
On iOS the SDK verifies each transaction on the device (StoreKit 2 checks its signed JWS payload before the SDK finishes it) and on the AppDNA server: under
.storeKit2 every completed purchase, every late purchase (interrupted, Ask-to-Buy, offer code) and every transaction a restore grants is sent to POST /api/v1/billing/verify in the background. Server verification never delays or fails a purchase: the transaction is queued on the device first (UserDefaults.standard, key appdna.pending_verifications_v1, up to 50 entries for 30 days) and sent after; a network error, 401, 429, 5xx or unreadable reply is retried at the next launch, foreground or Transaction.updates item, up to 10 attempts in all (then the entry is dropped and a warning logged), and any other 4xx (for example store_credentials_missing when App Store credentials are not set) is logged and dropped. Renewals, refunds and expiries still come from App Store Server Notifications — point your app’s notification URL at AppDNA to get them. You do not need to send receipts to your own server.Restore Purchases
Restore previously purchased products (e.g., after a device change or reinstall):String product identifiers that were restored.
Restore can fail
restorePurchases() throws; read the error with billingErrorType(error). Entitlements are left unchanged when it throws.
On iOS, treat any thrown error as a failed restore. A failed restore tracks
purchase_restore_failed (after configure; not when your own Task is cancelled — a CancellationError is rethrown untracked), and onRestoreCompleted does not fire.
Changed in 1.0.82: under
.revenueCat (not linked) a direct restore used to run a StoreKit restore through a silent StoreKit 2 fallback, and under .adapty (not linked) it returned [] and fired onRestoreCompleted([]). Both now throw providerNotAvailable. Under .none the error type is now providerNotAvailable (was unknown). Code that caught BillingModuleError.noBillingProvider must catch BillingError.providerNotAvailable instead.Entitlements
Retrieve the current user’s entitlements:Entitlement
Check Active Subscription
Quickly check if the user has any active subscription:Listen for Entitlement Changes
Register a callback to be notified when entitlements change: it fires once, on the main thread, whenever the entitlements really change — a product appears or disappears, or itsisActive or expiresAt changes. That covers a purchase, a restore, identify, a sign-out (reset()), a renewal (the expiry moves), an expiry and a refund or revocation. The SDK checks on Transaction.updates, on every return to the foreground, after each purchase / restore / identify / reset() (except with RevenueCat or Adapty linked into a source build — see Sign-out below) / refreshEntitlementCache(), and at the earliest expiresAt it knows. A check that finds the same state as the last one — including the first check after a relaunch — does not fire. The list is the device’s StoreKit entitlements plus, for an identified user, the entitlements GET /api/v1/billing/entitlements knows that this device does not hold (for example a purchase on another platform); when the server cannot be reached, the last server answer is reused if it belongs to the same user — also across a relaunch (the SDK stores one answer, the most recent user’s; reset() clears it) — so going offline is not a change. A check waits at most about 2.5 seconds for the server: when it is slower, the check goes on with that last answer, and the server’s reply, when it arrives, fires one more change if it differs (none if it matches, none after a reset() or a switch to another user). While that reply is outstanding, later checks for the same user wait on the same request instead of sending another one. The check after a purchase or restore runs in the background: purchase(...), restorePurchases() and a paywall’s purchase / restore success return as soon as StoreKit has answered, never waiting for the server — so the purchase can reach onEntitlementsChanged shortly after purchase(...) returns, and getEntitlements() (local, no network) already includes it:
reset() forgets the signed-out user’s server answer and checks once more, so a sign-out with no sign-in after it fires once with the anonymous state. On iOS the anonymous state is not an empty list: it is the device’s StoreKit entitlements, which belong to the Apple ID rather than to your app’s user (while no user is identified, every purchase on the Apple ID counts), without the server-only entitlements of the user who signed out. It fires only when that differs from the last state, so a user whose entitlements were all StoreKit purchases on this device sees no change. Android differs here: its reset() clears the entitlement cache and fires with an empty list (when it was not already empty). If identify follows before the sign-out check has finished, that check is dropped or reads the signed-in user, and the sign-in is reported once — one change in all, not two — as long as the server answers within about 2.5 seconds. When it is slower, the sign-in is first reported without the signed-in user’s server-only entitlements (the sign-out cleared the stored answer), and the server’s reply fires one more change when it arrives, if it adds entitlements (as described above). This check runs with billingProvider: .storeKit2, and with .revenueCat or .adapty when the provider’s SDK is not linked into AppDNA (every published CocoaPods / SPM build): there the SDK reads the entitlements from StoreKit itself (see Entitlements under .revenueCat / .adapty above), so the sign-out is reported exactly as under .storeKit2. Only with RevenueCat or Adapty linked into a source build does reset() forget the server answer without checking: the entitlements are then the linked provider’s answer for its current user, and reset() does not sign the provider out, so a check would report whichever user the provider still held. In that build, sign the provider out yourself, after reset() (Purchases.shared.logOut() / Adapty.logout()), so the next check reads neither the provider’s old user nor the signed-out user’s server answer. A linked RevenueCat then delivers its customer-info update, and the SDK checks for the provider’s new user and fires if that changed the state. A linked Adapty sends no such update: the change is reported at the next check (the next return to the foreground, or identify). To report it at once, call refreshEntitlementCache() after Adapty.logout(). With no billing provider configured, reset() does not check either.
AppDNABillingDelegate
All four methods fire on iOS. Register withAppDNA.billing.setDelegate(yourHandler) or AppDNA.billingDelegate = yourHandler.
onPurchaseCompletedandonPurchaseFailedfire for every purchase the SDK runs — paywall-driven or directAppDNA.billing.purchase(...). On.storeKit2,onPurchaseCompletedalso fires for purchases that complete later (interrupted, Ask-to-Buy or offer code), possibly at launch, onsetDelegateor afteridentify; a purchase made on another device of the same Apple ID may also be reported here, and a late purchase made while another user was signed in is delivered when that user signs in — grant idempotently bytransaction.transactionId. Those late purchases come from the SDK’s delivery queue, not from a billing bridge. A refused paywall tap (providerNotAvailable) calls no billing-delegate method, and a re-buy of an owned item fires noonPurchaseCompleted. A directpurchaseunder.revenueCatwithout RevenueCat linked into AppDNA, or under.adapty, callsonPurchaseFailed; so does a.storeKit2purchase whose App Store product lookup fails or throws; under.noneno billing-delegate method is called. A purchase whose SwiftTaskyou cancel rethrowsCancellationError, is not tracked aspurchase_failedand does not callonPurchaseFailed. Under.revenueCat(linked), a purchase the user cancels throwsBillingError.userCancelledand is tracked aspurchase_canceled.onRestoreCompletedfires after every successful restore; a refused restore does not call it.onEntitlementsChangedfires exactly when the closures registered withAppDNA.billing.onEntitlementsChanged { }above fire, with the same list, on the main thread. CallgetEntitlements()to re-read the set at any time.
AppDNAPaywallDelegate: when a purchase comes from a paywall, both AppDNAPaywallDelegate.onPaywallPurchaseCompleted AND AppDNABillingDelegate.onPurchaseCompleted fire. Each represents a different layer (paywall lifecycle vs platform billing). Pick one as your source of truth for your analytics — they will not double-count if you only listen on one. A purchase that completes later reaches only onPurchaseCompleted (it has no paywall).
Late purchases and the delivery queue
Under.storeKit2, a purchase that completes outside a live purchase() call — an interrupted purchase, an Ask-to-Buy approval or an offer code — is reported (purchase_completed, plus subscription_started for an auto-renewable subscription, with an empty paywall_id) and then queued for onPurchaseCompleted; one made while another user was signed in is reported when that user signs in. The queue is delivered right after each report, when a billing delegate is set, after identify, and at the end of configure.
- Any billing delegate drains the queue. Implement
onPurchaseCompleted, or register a delegate that must not receive queued purchases withsetDelegate(delegate, deliversPurchases: false). - Delivery is at least once: grant idempotently by
transactionId(a crash between the callback and the queue removal re-delivers once). - The queue keeps 100 entries for 30 days; past either, grant manually from your dashboard / support.
- Consumables are finished immediately; a consumable completed later is delivered the same way.
TransactionInfohas no quantity: a purchase with quantity > 1 under-grants (known).- The queue is stored in
UserDefaults.standardunderappdna.pending_deliveries_v1. Do not remove that key (for example withremovePersistentDomain(forName:)or a “clear all UserDefaults” debug action) while the SDK is configured: queued purchases would be lost and never reachonPurchaseCompleted.
AppDNABillingDelegate protocol:
Example Implementation
Auto-Tracked Events
The SDK automatically tracks the following billing-related events:These events include the product identifier and relevant metadata. They are tracked regardless of whether you implement
AppDNABillingDelegate. On .storeKit2, purchase_completed reports the charged price (transaction.price — the intro price for a paid intro, 0 for a free trial) and is_trial; the RevenueCat / Adapty bridges omit is_trial. subscription_renewed reports the renewal’s price (the price charged for the new period, transaction.price, else the product price) and currency — omitted when the SDK does not know them. Under .revenueCat the SDK does not emit subscription_renewed, subscription_canceled or subscription_renewal_failed — the RevenueCat webhook reports them.Full Example
Revenue the SDK cannot see
Supported on: iOS SDK
1.0.83+
