Supported on: iOS SDK
1.0.61+What’s new in 1.0.83
Paying users the SDK cannot see (Who counts as a paying user)- New
AppDNA.reportPayingUser(productId:priceCents:currency:)— tell AppDNA that the current user is in a paid state for a subscription the SDK never sold: billing run by your own server, a web checkout, a seat sold by a salesperson. It emitspaying_user_reportedwithsource: "host". - Every argument is optional, and the price is analytics only: the paying-user meter counts distinct users, not money. Safe to call on every launch — repeating it within a month costs nothing and misses nothing, which reporting the current state gets right and catching the upgrade transition does not.
- Call
identify()first. The SDK attaches the identity it already holds; there is deliberately no user-id argument, so an app can only ever report its own users. Beforeconfigure()the call logs at debug and returns. - Whether these reports count toward your bill is part of your agreement, and off until then — the events are ingested for analytics either way.
What’s new in 1.0.82
Experiments (Who Is in an Experiment)- The experiment’s Traffic Allocation and targeting rules (countries, minimum app version, new users only, user trait conditions) are now applied on the device. A user outside them gets
nilfromgetVariant/getExperimentConfig,falsefromisInVariant, the live entity on a servable surface, and noexperiment_exposure. Before, every user on a targeted platform was assigned a variant. Allocation uses its own hash (<experimentId>.<salt>.<userId>.allocation), so the variant split is unchanged and raising the allocation keeps everyone who was in. An experiment written by an older server (notraffic_allocation) still serves everyone.
- A 401 or 403 from the event upload (an invalid or revoked API key) pauses uploads at once — the in-app queue and the background upload (
BGProcessingTask) — until the app comes to the foreground again or you callAppDNA.flush(); before, the queue dropped a batch on each of 5 failed cycles first. Any other permanent 4xx drops only its batch. - An event queued while an older SDK’s queue file could not be migrated yet now starts its own line; it joined the file’s last line, and the next read lost every event in the file. Stored events another upload already delivered are removed when the in-app queue skips them, so they can no longer stop it from reaching the events behind them.
- The 10,000-event queue limit holds after every event queued (it was applied every 500 events, so the queue could hold about 500 more), and a background upload sends batch after batch, up to 50 per run, until the queue is empty (it sent one batch per run), as on Android.
- The
country,region,cityandtimezonetraits the SDK takes from the IP address stay throughidentify(userId:traits:), a change of user andreset()(a trait you set with the same name wins);identifywith traits used to drop them until the next app start.
- The SDK finishes StoreKit transactions only under
billingProvider: .storeKit2(the default). Under.revenueCat/.adaptyit never finishes a transaction; before, a.revenueCathost on any published build silently fell back to StoreKit 2 and finished every update. - Under
.revenueCat/.adapty(provider not linked into AppDNA) and.none, an SDK paywall tap emitspurchase_failedand callsonPaywallPurchaseFailed(errorType: "providerNotAvailable", productId:)(before: a silent tap under.none, a StoreKit purchase under.revenueCat); the paywall’s failure route runs. Start the purchase with your provider from that callback. AppDNA.billing.purchase/restorePurchaseswith no bridge now throwBillingError.providerNotAvailableinstead ofBillingModuleError.noBillingProvider. The old case is now@available(*, deprecated): code that names it still compiles, with a deprecation warning, but it never matches.- Restore: a direct
restorePurchases()now throwsproviderNotAvailablewith.revenueCatnot linked (it ran a StoreKit restore) and with.adaptynot linked (it returned[]and firedonRestoreCompleted([])); under.nonethe error type is nowproviderNotAvailable(wasunknown). A failed direct restore trackspurchase_restore_failed. - Under
.revenueCatthe SDK no longer emitssubscription_renewed,subscription_canceledorsubscription_renewal_failed; the RevenueCat webhook is the source..adaptykeeps them. - Late purchases (
.storeKit2): interrupted, Ask-to-Buy and offer-code purchases are now reported (purchase_completed,subscription_started,onPurchaseCompleted) — possibly later, through a delivery queue drained onsetDelegate, afteridentifyand at the end ofconfigure. Grant idempotently bytransactionId. A late purchase made while another user was signed in is delivered when that user signs in. - New
AppDNA.billing.setDelegate(_:deliversPurchases:)(additive): passfalsefor a delegate that must not receive queued late purchases. - On
.storeKit2, a re-buy of an owned item emitspurchase_restoredwithreason: "item_already_owned"instead of a secondpurchase_completed, and fires noonPurchaseCompleted(purchase()still returns theTransactionInfo). - On
.storeKit2, purchases reportis_trialand the charged price (0 for a free trial, the intro price for a paid intro); the RevenueCat / Adapty bridges omitis_trial. - Event parity:
purchase_started/purchase_failedon direct purchases;error_typeon the paywallpurchase_restore_failed;purchase_restore_failed{error_type: providerNotAvailable}for a paywall restore without a bridge. purchase/restorePurchases()called beforeconfigurecompletes fail with anunknownerror, “AppDNA SDK not configured yet — call configure() first” (was “No billing provider configured”). A paywall plan or Restore tap that reaches the SDK while billing is not configured (beforeconfigureor duringshutdown()) fails with the sameunknownerror, without a Started callback. Thepurchase_failedof a refused plan tap, and thepurchase_restore_failedof a refused Restore tap, carryreason: "not_configured". A directpurchase/restorePurchases()beforeconfigurethrows and tracks no event.- A paywall restore that ends in a Swift
CancellationErroris not reported as failed: noonPaywallRestoreFailedand nopurchase_restore_failed. Every other restore error is reported. - The paywall’s purchase button is enabled again after every failed, cancelled or pending purchase, and after a successful purchase when it has no
on_successaction or one this SDK version does not know (the paywall stays up for your app to close). Before, a failed purchase re-enabled it only when the paywall’s failure action wasshow_errororretry(dismissclosed the paywall), and a successful one never did; otherwise it stayed disabled with its spinner. - That holds even when your app covers the paywall (for example with a full-screen cover) while the purchase runs: the button is enabled when the paywall is visible again. The error banner (failure action
show_errororretryonly) is shown when the purchase fails; ashow_errorbanner hides itself after 4 s. - One device-wide owner map for purchase tokens (up to 500 entries, not cleared by
reset()oridentify), including a customPurchaseOptions.appAccountToken. - Entitlement changes (Listen for entitlement changes):
AppDNABillingDelegate.onEntitlementsChangedis now called (it never was on iOS), together with theAppDNA.billing.onEntitlementsChanged { }closures and with the same list. Both now fire on a renewal, an expiry, a refund or revocation as well as on a purchase, restore oridentify— the SDK checks onTransaction.updates, on each foreground and at the earliest known expiry — and only on a real change (a relaunch with unchanged entitlements no longer fires).Entitlement.expiresAtcarries StoreKit’s expiry andisActiveturns false once it has passed, ongetEntitlements()too; a server entitlement inbilling_retrycounts as active, as on the server and Android. For an identified user the list also includes entitlements/billing/entitlementsknows that this device does not hold. - Server verification (
.storeKit2): completed, late and restored purchases are now sent to/billing/verifyin the background (queued on the device, retried; never delays or fails the purchase). TransactionInfo.environmentreports the StoreKit environment (production,sandbox,xcode) instead of alwaysproduction.subscription_renewedcarriespriceandcurrency.- A direct
purchase()whose SwiftTaskis cancelled rethrowsCancellationErrorand tracks nopurchase_failed. A.storeKit2purchase whose product lookup throws now callsonPurchaseFailed. .adaptywith Adapty linked into AppDNA (source builds): compiled once against Adapty 3.17.3 (includingAdapty.activate(apiKey); a one-off build, not a CI job); the SDK restores through Adapty but no longer offers to buy (Adapty buys only its own paywall products) — a paywall tap reportsproviderNotAvailable, as without Adapty linked. Restored and entitled ids are the product ids behind active access levels..revenueCatwith RevenueCat linked (compiled once against RevenueCat 4.44.3 and 5.92.0, also a one-off build) likewise reports the product ids behind active entitlements, not the entitlement names. A purchase refused under.adaptytracks onlypurchase_failed, with nopurchase_started(the Adapty bridge used to emitpurchase_starteditself as well).- An unreadable delivery-queue or verification-queue store is logged and kept under
<key>.corrupt.<epoch-ms>(for exampleappdna.pending_deliveries_v1.corrupt.1790000000000) instead of being silently replaced. The newest three copies per store are kept; their keys are listed under<key>.corrupt.index. A single<key>.corruptcopy left by an earlier SDK is listed there too, as the oldest copy, and is removed like one. - A subscription in its billing grace period, or a server entitlement in
grace_period/billing_retry, is reported active (isActive,onEntitlementsChanged) although itsexpiresAthas passed; before, it read inactive, and the scheduled expiry check flipped it. - Server-only entitlements (a purchase on another platform) are kept across an offline relaunch for the user they were last read for; before, the first offline refresh after a launch reported them gone.
purchase(...),restorePurchases()and a paywall’s purchase / restore success never wait on the AppDNA server: they return (or callonPaywallPurchaseCompleted/onPaywallRestoreCompletedand auto-dismiss) as soon as StoreKit has answered, and the entitlement check that follows runs in the background. An entitlement check waits at most about 2.5 seconds for/billing/entitlements, then goes on with the last answer for the same user; a slower reply fires one moreonEntitlementsChangedwhen it arrives, if it changes the list (Listen for entitlement changes). While that reply is outstanding, later checks for the same user wait on the same request rather than sending another, and a reply that arrives as the 2.5 seconds run out is used by that check — it is not reported a second time.- Under
.storeKit2, and under.revenueCat/.adaptywithout the provider’s SDK linked (every published channel), an entitlement’sexpiresAtcomes only from the transactions that also decide which products the current user has. With RevenueCat or Adapty linked into a source build, it is the latest expiry among the device’s unrevoked StoreKit transactions of the product the provider reports for its current user. reset()clears the stored server-only entitlements of the signed-out user, including when an entitlement refresh is in flight during the sign-out (its answer is dropped, not saved).reset()now firesonEntitlementsChanged(closures andAppDNABillingDelegate) once with the signed-out state — the device’s StoreKit entitlements without the signed-out user’s server-only ones — when that differs from the last state; before, a sign-out reported nothing. A sign-in straight after it is still reported once. This applies withbillingProvider: .storeKit2, and with.revenueCat/.adaptywhen the provider’s SDK is not linked into AppDNA (every published CocoaPods / SPM build — the SDK reads the entitlements from StoreKit itself). Only with RevenueCat or Adapty linked into a source build doesreset()not check (the linked provider’s entitlements belong to the provider’s current user; sign the provider out afterreset(), see Billing). With no billing provider it no longer queues a check that logged “no billing provider configured” on everyreset().- Adding RevenueCat (
purchases-ios) or Adapty to your app through Swift Package Manager no longer links it into AppDNA: the SDK’s provider bridge is compiled only in a source build that definesAPPDNA_LINK_REVENUECAT/APPDNA_LINK_ADAPTY(Billing). Before, a Swift Package Manager app that addedpurchases-ioscould make AppDNA buy and restore through RevenueCat under.revenueCat, unlike CocoaPods. - RevenueCat: a purchase the user cancels is
BillingError.userCancelled(tracked aspurchase_canceled; Flutter returns{status: "cancelled"}, React Native rejects with the cancel code). It was RevenueCat’s raw error, tracked aspurchase_failed. - A purchase whose Task you cancel during the product lookup no longer calls
onPurchaseFailed. - Push: a Dismiss button never opens the app. The Notification Service Extension helper is now its own extension-safe library,
AppDNANotificationExtension(see Push);AppDNASDK.NotificationServicestill compiles, but an extension must linkAppDNANotificationExtensioninstead ofAppDNASDK. Button categories are registered one at a time (two at once could drop one), with the same titles from the extension and the app, and past 16 the oldest ones whose notifications are no longer in Notification Centre are removed. A category dropped because the extension and the app registered at the same moment is added back.AppDNANotificationExtensionships its own privacy manifest. diagnose()reports the veto timeout as configured (0.5s, not0s).
- Sign-in actions: the Flutter and React Native bridges wait at least 120 s for
onBeforeStepAdvance(StepAdvanceResult.minimumBridgeTimeout(stepData:), new). Native iOS hosts are unchanged. - Interactive map (new). The iOS SDK now depends on the
GoogleMapspod (~> 9.4; Swift Package Manager:ios-maps-sdkfrom 9.4.0). A map block with pan & zoom on and the Google provider draws a pannable Google map with the routepolyline(Google, precision 5; otherwise straight segments between the stops). With auto-fit on (the default) the camera fits the route and its stops, and a single point uses zoom 15; with auto-fit off it centres on the authored centre at the authored zoom. Without the Google key (delivered from your AppDNA configuration, like the Mapbox token) a Google-provider map block shows its fallback text (“Map unavailable” unless the author set one): the still Google image needs the same key. getLocationDatano longer crashes and returns a partial object (formatted_address,raw_query,nilcoordinates) for typed-but-unselected text. Both location writers (the form-step Location field, which used to store nothing for typed text, and the Location content block) now store the same shape: a missing city / state / country is left out rather than stored as"", a failed time-zone lookup (on-device in the content block, or the AppDNA geocoding lookup) stores no zone rather than"UTC", and a selection also storestimezone_offsetandraw_query.LocationData’s non-optional fields other thanformatted_address(city,state,state_code,country,country_code,latitude,longitude,timezone,timezone_offset,raw_query) became optional — a source change for Swift code that reads them unwrapped (postal_codealready was optional).- A Flutter / React Native
onBeforeStepAdvancereplyskipTo(orskipToWithData) without astepId, or with a blank one, is no longer a skip: on a sign-in step the bridge blocks it as “no answer” (it advanced the user past the sign-in step), elsewhere it proceeds (with itsdata, if any). NewStepAdvanceResult.isExplicitBridgeDecision(_:)/bridgeSkipTarget(reply:)hold that rule for both bridges. Native iOS hosts are unchanged. - A
social_logintap on a step with an input field whose id isactionorprovidernow still reportsaction: "social_login"and the button’s provider (the input used to override them, which also took the tap out of the sign-in handling). - A session value or onboarding answer that is not valid JSON (for example
Double.nan) is still not saved to disk, but now logs a warning naming it. Every other saved value is kept (before, one such value kept the whole store from being saved). - The SDK no longer references the Contacts, EventKit, App Tracking Transparency or Photos-library APIs unless your flow uses those permission steps. Every host still needs
NSLocationWhenInUseUsageDescription. If App Store Connect still asks for a key, add it and tell us. See Apple permission strings.
- The SDK installs a notification-centre delegate proxy at launch that chains to your existing delegate: AppDNA pushes are tracked and routed (including cold-start taps) without code. Opt out with Info.plist
AppDNADisableNotificationProxy = YES; set foreground presentation withAppDNAForegroundPresentation. NewAppDNA.installNotificationProxy(). - New forwarding API:
AppDNA.pushModule.isAppDNAMessage,handleMessageData,handleNotificationTap. Only pushes markedappdna: "1"are handled. - Tap routing follows one ladder: the tapped button, then the push’s
action, then flataction_type/action_value, thenscreen_id, thendeep_link— the first button’s action is used only when none of those is present. - With a push library that wraps the notification delegate (FlutterFire, RNFirebase, notifee) as the outer notification delegate, a push arriving in the foreground keeps that library’s presentation and is not tracked as delivered unless the delegate AppDNA wrapped implements
willPresentorAppDNAForegroundPresentationis set. Taps are still tracked in any app state, because these libraries forward taps to the delegate they wrapped (not when a delegate assigned later drops the chain, or withAppDNADisableNotificationProxyand no forwarding). - With no notification delegate and no push library, an AppDNA push arriving in the foreground is now shown (AppDNA’s foreground options), tracked as delivered and passed to
onPushReceived— before, it was not shown. - When the SDK ends up as the outer delegate (your delegate was set in
didFinishLaunching) and your delegate implementswillPresentorAppDNAForegroundPresentationis set, AppDNA’s foreground options apply to AppDNA pushes and your delegate’swillPresentis not called for them (if your delegate does not implementwillPresentand the key is not set, iOS does not ask: an AppDNA push arriving in the foreground is not shown and not tracked as delivered); other pushes are forwarded to your delegate when it implements the method (otherwise completed with no presentation, the iOS default). The exception is theAppDNAForegroundPresentationtrade-off. push_delivered/push_tappedcarrydelivery_id(requires an AppDNA server from this release or later).
- New properties:
emitted_by("sdk"on SDK billing events;AppDNA.tracknow drops a host-passedemitted_by/_appdna_origin, including beforeconfigure),transaction_id/original_transaction_idon the subscription lifecycle events,original_transaction_idonpurchase_completedandsubscription_started,cancel_semanticsonsubscription_canceled,is_consumableonpurchase_completed,delivery_idon push events. Int64event properties (epoch milliseconds such aspurchase_time) are now sent as JSON numbers; they were sent as strings.- Every event carries the device’s time zone,
device.timezone(the IANA name fromTimeZone.current, read per event). AppDNA keeps the latest one on the identified user’s profile, so pushes sent in the user’s time zone, push quiet hours and journey waits use it without atimezonetrait; a trait you pass toidentifystill takes precedence (Push). Requires an AppDNA server from this release.
- A failed bootstrap no longer lasts the whole session. When the bootstrap fails (for example, the app starts offline), the SDK still becomes ready on cached and bundled config and reports
.bootstrapFailedthroughonInitDegraded, and now retries the bootstrap: when the network comes back, when the app comes to the foreground, and otherwise after 5 seconds, doubling to 5 minutes, each wait ±25 % — at most 10 retries perconfigure(), none while offline. Every trigger (foreground or network regained) waits a random 0–1 s before its retry. A 401 or 403 (the server refused the API key) ends the retries, and one atconfigure()starts none; a 429’sRetry-After(up to 120 seconds) holds the next retry back. A retry that succeeds is applied as a first success would be: remote config is fetched, the web-entitlement and journey-message listeners of a signed-in user start, deferred deep links become available, the runtime lock applies, andlastInitErrorreturns tonil;onReadydoes not fire again. Before, the SDK stayed on cached config until the nextconfigure(). - After
shutdown()and a newconfigure(), events the old session’s last upload delivered are no longer sent again, and an event the new session queued while that upload finished is no longer lost from disk. A background upload that finds another upload running now schedules the next one instead of ending. After 5 failed upload cycles the pause now also holds for the background upload: backgrounding schedules none, and one that runs while paused uploads nothing. The persisted pause belongs to the newest session: the last upload of a sessionshutdown()ended cannot pause the next one. - A large queue of unsent events (up to 10,000) no longer delays
configure()aftershutdown(), and no longer makes every upload slower. Each upload used to read the whole queue file twice; now it reads only the events it sends, andconfigure()no longer waits for the queue to load. - A
shutdown()while the SDK is still starting now ends that start: a bootstrap that answers aftershutdown()is ignored, soonReadydoes not fire and no module is rebuilt on the shut-down SDK (before, the SDK became ready again by itself). Aftershutdown()and a newconfigure(), only the new configure’s bootstrap applies; a late answer for the first one no longer makes the SDK ready early or replaces the new one’s configuration.
Requirements
Before integrating the AppDNA iOS SDK, ensure your project meets the following minimum requirements:Installation
Swift Package Manager (Recommended)
Add the AppDNA SDK to your project using Swift Package Manager:- In Xcode, go to File > Add Package Dependencies…
- Enter the repository URL:
- Set the dependency rule to Up to Next Major Version starting from
1.0.83. - Select the
AppDNASDKpackage product and add it to your target.
Package.swift:
CocoaPods
Add the following to yourPodfile:
use_frameworks!, dynamic (use_frameworks!) or static (use_frameworks! :linkage => :static). The SDK cannot be built from pods built as static libraries (no use_frameworks!): pod install stops because Firebase’s Swift pods depend on pods that do not define modules, and with modular headers added the build fails later instead (gRPC-C++ does not compile, or, with modular headers on only the pods that need them, the app does not link, because its FirebaseFirestore dependency links FirebaseFirestoreInternal as a framework, which a static-library build does not produce).
Then run:
The pod is published from the
AppDNASDK.podspec in the SDK repository. Make sure your CocoaPods repo is up to date by running pod repo update if the pod is not found.Firebase Configuration
The AppDNA SDK uses Firebase Firestore for configuration delivery (paywalls, experiments, feature flags, onboarding flows). You must add a Firebase configuration file to your project.Step 1: Download GoogleService-Info-AppDNA.plist
- Log into your AppDNA Console
- Go to Settings → SDK
- Click Download Firebase Config to download
GoogleService-Info-AppDNA.plist
Step 2: Add to Your Xcode Project
- Drag
GoogleService-Info-AppDNA.plistinto your Xcode project navigator - Ensure “Copy items if needed” is checked
- Select your app target in the “Add to targets” section
Step 3: Add Background Task Permission
Add the following to your app’sInfo.plist to enable background event uploads:
If your app already uses Firebase for your own services (Realtime Database, Analytics, Crashlytics), AppDNA automatically creates a separate named Firebase instance using
GoogleService-Info-AppDNA.plist. Your existing Firebase setup is not affected. Just add the AppDNA plist alongside your own GoogleService-Info.plist.Dependencies
The following dependencies are automatically included with the SDK. You do not need to add them manually:If your project already includes these dependencies, ensure your version constraints are compatible with the versions listed above. SPM and CocoaPods will resolve version conflicts automatically in most cases.
Lottie and Rive animation support requires adding
lottie-ios and rive-ios as separate SPM dependencies in your project.Interactive maps
The static map needs nothing: the iOS Maps SDK takes its key at runtime and that key arrives with your configuration. (Android differs — it needs one<meta-data> line in the host manifest.)
Enable the Maps SDK for this platform in Google Cloud, alongside the Maps Static API the map
image already uses: APIs & Services → Library → Maps SDK for iOS. Pan & zoom is drawn by that SDK, so
without it enabled the key is rejected at map start and the step falls back to the still image —
every bit of wiring below can be correct and the map still will not move. See
Maps for the full key setup.
Verify Installation
After adding the SDK, verify it is correctly installed by importing the module and printing the SDK version:You should see
1.0.83 printed in the Xcode console. If you get a “No such module” error, clean your build folder (Cmd+Shift+K) and rebuild.Troubleshooting
If you encounter issues during integration, callAppDNA.diagnose() after configuration to get a full health report:

