Skip to main content
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 emits paying_user_reported with source: "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. Before configure() 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 nil from getVariant / getExperimentConfig, false from isInVariant, the live entity on a servable surface, and no experiment_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 (no traffic_allocation) still serves everyone.
Event uploads
  • 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 call AppDNA.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, city and timezone traits the SDK takes from the IP address stay through identify(userId:traits:), a change of user and reset() (a trait you set with the same name wins); identify with traits used to drop them until the next app start.
Billing ownership (Billing)
  • The SDK finishes StoreKit transactions only under billingProvider: .storeKit2 (the default). Under .revenueCat / .adapty it never finishes a transaction; before, a .revenueCat host 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 emits purchase_failed and calls onPaywallPurchaseFailed(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 / restorePurchases with no bridge now throw BillingError.providerNotAvailable instead of BillingModuleError.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 throws providerNotAvailable with .revenueCat not linked (it ran a StoreKit restore) and with .adapty not linked (it returned [] and fired onRestoreCompleted([])); under .none the error type is now providerNotAvailable (was unknown). A failed direct restore tracks purchase_restore_failed.
  • Under .revenueCat the SDK no longer emits subscription_renewed, subscription_canceled or subscription_renewal_failed; the RevenueCat webhook is the source. .adapty keeps 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 on setDelegate, after identify and at the end of configure. Grant idempotently by transactionId. A late purchase made while another user was signed in is delivered when that user signs in.
  • New AppDNA.billing.setDelegate(_:deliversPurchases:) (additive): pass false for a delegate that must not receive queued late purchases.
  • On .storeKit2, a re-buy of an owned item emits purchase_restored with reason: "item_already_owned" instead of a second purchase_completed, and fires no onPurchaseCompleted (purchase() still returns the TransactionInfo).
  • On .storeKit2, purchases report is_trial and the charged price (0 for a free trial, the intro price for a paid intro); the RevenueCat / Adapty bridges omit is_trial.
  • Event parity: purchase_started / purchase_failed on direct purchases; error_type on the paywall purchase_restore_failed; purchase_restore_failed{error_type: providerNotAvailable} for a paywall restore without a bridge.
  • purchase / restorePurchases() called before configure completes fail with an unknown error, “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 (before configure or during shutdown()) fails with the same unknown error, without a Started callback. The purchase_failed of a refused plan tap, and the purchase_restore_failed of a refused Restore tap, carry reason: "not_configured". A direct purchase / restorePurchases() before configure throws and tracks no event.
  • A paywall restore that ends in a Swift CancellationError is not reported as failed: no onPaywallRestoreFailed and no purchase_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_success action 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 was show_error or retry (dismiss closed 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_error or retry only) is shown when the purchase fails; a show_error banner hides itself after 4 s.
  • One device-wide owner map for purchase tokens (up to 500 entries, not cleared by reset() or identify), including a custom PurchaseOptions.appAccountToken.
  • Entitlement changes (Listen for entitlement changes): AppDNABillingDelegate.onEntitlementsChanged is now called (it never was on iOS), together with the AppDNA.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 or identify — the SDK checks on Transaction.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.expiresAt carries StoreKit’s expiry and isActive turns false once it has passed, on getEntitlements() too; a server entitlement in billing_retry counts as active, as on the server and Android. For an identified user the list also includes entitlements /billing/entitlements knows that this device does not hold.
  • Server verification (.storeKit2): completed, late and restored purchases are now sent to /billing/verify in the background (queued on the device, retried; never delays or fails the purchase).
  • TransactionInfo.environment reports the StoreKit environment (production, sandbox, xcode) instead of always production.
  • subscription_renewed carries price and currency.
  • A direct purchase() whose Swift Task is cancelled rethrows CancellationError and tracks no purchase_failed. A .storeKit2 purchase whose product lookup throws now calls onPurchaseFailed.
  • .adapty with Adapty linked into AppDNA (source builds): compiled once against Adapty 3.17.3 (including Adapty.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 reports providerNotAvailable, as without Adapty linked. Restored and entitled ids are the product ids behind active access levels. .revenueCat with 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 .adapty tracks only purchase_failed, with no purchase_started (the Adapty bridge used to emit purchase_started itself as well).
  • An unreadable delivery-queue or verification-queue store is logged and kept under <key>.corrupt.<epoch-ms> (for example appdna.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>.corrupt copy 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 its expiresAt has 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 call onPaywallPurchaseCompleted / onPaywallRestoreCompleted and 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 more onEntitlementsChanged when 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 / .adapty without the provider’s SDK linked (every published channel), an entitlement’s expiresAt comes 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 fires onEntitlementsChanged (closures and AppDNABillingDelegate) 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 with billingProvider: .storeKit2, and with .revenueCat / .adapty when 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 does reset() not check (the linked provider’s entitlements belong to the provider’s current user; sign the provider out after reset(), see Billing). With no billing provider it no longer queues a check that logged “no billing provider configured” on every reset().
  • 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 defines APPDNA_LINK_REVENUECAT / APPDNA_LINK_ADAPTY (Billing). Before, a Swift Package Manager app that added purchases-ios could make AppDNA buy and restore through RevenueCat under .revenueCat, unlike CocoaPods.
  • RevenueCat: a purchase the user cancels is BillingError.userCancelled (tracked as purchase_canceled; Flutter returns {status: "cancelled"}, React Native rejects with the cancel code). It was RevenueCat’s raw error, tracked as purchase_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.NotificationService still compiles, but an extension must link AppDNANotificationExtension instead of AppDNASDK. 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. AppDNANotificationExtension ships its own privacy manifest.
  • diagnose() reports the veto timeout as configured (0.5s, not 0s).
Onboarding
  • 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 GoogleMaps pod (~> 9.4; Swift Package Manager: ios-maps-sdk from 9.4.0). A map block with pan & zoom on and the Google provider draws a pannable Google map with the route polyline (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.
  • getLocationData no longer crashes and returns a partial object (formatted_address, raw_query, nil coordinates) 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 stores timezone_offset and raw_query. LocationData’s non-optional fields other than formatted_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_code already was optional).
  • A Flutter / React Native onBeforeStepAdvance reply skipTo (or skipToWithData) without a stepId, 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 its data, if any). New StepAdvanceResult.isExplicitBridgeDecision(_:) / bridgeSkipTarget(reply:) hold that rule for both bridges. Native iOS hosts are unchanged.
  • A social_login tap on a step with an input field whose id is action or provider now still reports action: "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.
Push (Push)
  • 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 with AppDNAForegroundPresentation. New AppDNA.installNotificationProxy().
  • New forwarding API: AppDNA.pushModule.isAppDNAMessage, handleMessageData, handleNotificationTap. Only pushes marked appdna: "1" are handled.
  • Tap routing follows one ladder: the tapped button, then the push’s action, then flat action_type / action_value, then screen_id, then deep_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 willPresent or AppDNAForegroundPresentation is 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 with AppDNADisableNotificationProxy and 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 implements willPresent or AppDNAForegroundPresentation is set, AppDNA’s foreground options apply to AppDNA pushes and your delegate’s willPresent is not called for them (if your delegate does not implement willPresent and 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 the AppDNAForegroundPresentation trade-off.
  • push_delivered / push_tapped carry delivery_id (requires an AppDNA server from this release or later).
Events
  • New properties: emitted_by ("sdk" on SDK billing events; AppDNA.track now drops a host-passed emitted_by / _appdna_origin, including before configure), transaction_id / original_transaction_id on the subscription lifecycle events, original_transaction_id on purchase_completed and subscription_started, cancel_semantics on subscription_canceled, is_consumable on purchase_completed, delivery_id on push events.
  • Int64 event properties (epoch milliseconds such as purchase_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 from TimeZone.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 a timezone trait; a trait you pass to identify still takes precedence (Push). Requires an AppDNA server from this release.
Lifecycle
  • 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 .bootstrapFailed through onInitDegraded, 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 per configure(), 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 at configure() starts none; a 429’s Retry-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, and lastInitError returns to nil; onReady does not fire again. Before, the SDK stayed on cached config until the next configure().
  • After shutdown() and a new configure(), 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 session shutdown() ended cannot pause the next one.
  • A large queue of unsent events (up to 10,000) no longer delays configure() after shutdown(), 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, and configure() no longer waits for the queue to load.
  • A shutdown() while the SDK is still starting now ends that start: a bootstrap that answers after shutdown() is ignored, so onReady does not fire and no module is rebuilt on the shut-down SDK (before, the SDK became ready again by itself). After shutdown() and a new configure(), 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.
Upgrading to 1.0.77: one possible compile error.1.0.77 adds a case to the public AppDNAInitError enum — unsupportedBlockType(String), which reports a content block your SDK version is too old to render instead of drawing nothing at all.Adding an enum case is source-breaking in Swift. If your code switches over AppDNAInitError exhaustively, it will stop compiling with:
The fix is one line — handle the new case, or add a default::
Nothing changes at runtime, and no other API changed. If you do not switch over AppDNAInitError, upgrading needs no work.This shipped as a patch, so a ~> 1.0.76 requirement picks it up automatically.

Requirements

Before integrating the AppDNA iOS SDK, ensure your project meets the following minimum requirements:
Projects targeting iOS versions below 16.0 are not supported. The SDK uses Swift concurrency features and modern SwiftUI APIs that require iOS 16.0 as a minimum deployment target.

Installation

Add the AppDNA SDK to your project using Swift Package Manager:
  1. In Xcode, go to File > Add Package Dependencies…
  2. Enter the repository URL:
  1. Set the dependency rule to Up to Next Major Version starting from 1.0.83.
  2. Select the AppDNASDK package product and add it to your target.
Alternatively, add it directly to your Package.swift:
Then add the product to your target’s dependencies:

CocoaPods

Add the following to your Podfile:
The Podfile needs 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

  1. Log into your AppDNA Console
  2. Go to Settings → SDK
  3. Click Download Firebase Config to download GoogleService-Info-AppDNA.plist

Step 2: Add to Your Xcode Project

  1. Drag GoogleService-Info-AppDNA.plist into your Xcode project navigator
  2. Ensure “Copy items if needed” is checked
  3. Select your app target in the “Add to targets” section

Step 3: Add Background Task Permission

Add the following to your app’s Info.plist to enable background event uploads:
Without GoogleService-Info-AppDNA.plist, the SDK cannot fetch remote configuration (paywalls, experiments, feature flags). Events will still be tracked, but remote features will not work.
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.
Pan & zoom needs GoogleMaps.bundle at your app bundle’s root, and SwiftPM does not put it there. The Maps SDK loads that resource from the main bundle; SwiftPM nests it inside the dependency’s own bundle as GoogleMaps_GoogleMapsTarget.bundle/GoogleMaps.bundle, where the Maps SDK does not look.🔴 Adding the GoogleMaps package to your app target does NOT fix this — we tested it, and the resource is still nested. Either:
  • Use CocoaPods for AppDNASDK, which copies the bundle to the right place (this is why React Native and Flutter hosts are unaffected), or
  • Add a Copy Files build phase on your app target that copies GoogleMaps_GoogleMapsTarget.bundle/GoogleMaps.bundle into the app bundle’s root.
Without one of those, nothing breaks and nothing crashes: the SDK detects the missing bundle and falls back to the static map. You get a picture of the right place — it just will not pan or zoom, which is easy to mistake for the feature being broken.
The key is always yours: Google bills map requests to the account that owns the key, so AppDNA never substitutes one of its own.

Verify Installation

After adding the SDK, verify it is correctly installed by importing the module and printing the SDK version:
If the import succeeds and the version prints correctly, the SDK is ready to configure.
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, call AppDNA.diagnose() after configuration to get a full health report:
This prints a diagnostic report to the Xcode console:
Any items marked with ❌ indicate a configuration issue. Common fixes:

Next Steps

Once the SDK is installed, proceed to the Quickstart guide to configure the SDK and start tracking events.