Skip to main content

Competitive Research: iOS SDK Design of Deep-Linking / Attribution Platforms

Web research for the OptoLink native Swift SDK. Vendors: Branch, AppsFlyer, Adjust, Singular, Kochava, Tenjin + the post-Firebase-Dynamic-Links landscape and modern lightweight entrants. Researched 2026; sources inline. SDK facts verified against vendor source/headers where noted. Companion to android-sdk-competitive-research.md — iOS deltas are called out throughout.


1. Deferred deep linking on iOS: why it is structurally harder than Android​

iOS has no install referrer. There is no OS-provided channel that hands the app the URL the user clicked before installing (Android's Play Install Referrer is the deterministic backbone there). Apple killed every viable workaround one by one: no referrer API, SFSafariViewController/App-Store-page cookie access gone, and since iOS 14.5 the ATT policy officially prohibits device fingerprinting for ad tracking. Every vendor's iOS DDL is therefore one of three hacks (or a cascade of them):

1.1 The three matching mechanisms used in practice​

#MechanismHow it worksUsed byStrength / weakness
1Pasteboard (NativeLink™-style) — deterministicThe link's web landing page (Deepview) writes a token to the pasteboard via JS; SDK reads it on first launch and matches 100%Branch NativeLink (v1.39.4+); AppsFlyer variant; old Adjust pasteboard matching100% match, but iOS 16+ shows the system "allow paste" prompt to every new user — a real onboarding UX cost vendors now document openly. https://help.branch.io/docs/nativelink-deferred-deep-linking
2Server-side probabilistic matching (fingerprint)Click page records IP, OS, device model, language, timezone; SDK sends the same signals at first open; server stitches click↔install in a match window (24h typical; Branch guarantees-flag only at 100% confidence)Branch ("snapshot comparison"), AppsFlyer, Adjust, Singular, Kochava, all lightweight vendorsNo prompt, no IDFA, survives ATT — but confidence degrades with CGNAT/iCloud Private Relay (shared exit IPs); Apple's ATT policy formally objects to fingerprinting for identification, so vendors frame it as "matching" for routing, not ad attribution. https://warplink.app/blog/deferred-deep-linking-ios
3Deterministic click-ID via install-time session callFirst session/attribution network call carries IDFV (vendor-scoped, resets when all vendor apps are deleted) and any stored click context; server returns matched link in the session/attribution response itselfAdjust (optimized DDL in /session response, legacy in /attribution), AppsFlyer conversion data + UDL, Kochava attribution responseSame-device reinstall matching is deterministic (IDFV) — but cross-app first-install matching still needs #1 or #2. https://dev.adjust.com/en/sdk/ios/features/deep-links/deferred/

Plus one Apple-only deterministic channel: Apple Search Ads. AdServices framework (attributionAttributionToken, iOS 14.3+) gives download attribution for ASA campaigns only. Singular ships AdServices as an optional (weak-linked) framework — the modern pattern. https://github.com/singular-labs/Singular-iOS-SDK

SKAdNetwork is NOT deep linking — it's privacy-preserving postback attribution with no link data delivered to the app. None of the vendors use SKAN for DDL; OptoLink shouldn't conflate them.

  • Requires applinks:<domain> entitlement + AASA (apple-app-site-association) hosted at https://<link-domain>/.well-known/ with appID = <TeamID>.<bundleID>. Same custom-domain-hosting model as Android assetlinks.json — one CDN, two files.
  • Branch generates and hosts AASA for its app.link domains and requires the app to register three host variants: the base domain, -alternate (needed for Universal Links + Deepviews for users without the app), and .test for test keys. https://help.branch.io/developers-hub/docs/ios-basic-integration
  • Cold-start on iOS: SceneDelegate apps must fish NSUserActivity/URLContexts out of connectionOptions in scene(_:willConnectTo:) — every vendor documents this exact workaround (Branch ships a BranchScene wrapper class for it). https://help.branch.io/developers-hub/docs/ios-basic-integration

1.3 ATT handling patterns​


2. Vendor-by-vendor​

2.1 Branch.io — iOS SDK​

AspectFact
Integration shape(1) Dashboard: enable Universal Links, set Team ID as "Apple App Prefix" + bundle IDs; (2) Xcode: applinks: entitlement for link domain + -alternate + .test variants; (3) initSession in didFinishLaunchingWithOptions + forward application(_:open:) and continue(userActivity:) to the SDK; (4) optional checkPasteboardOnInstall() for NativeLink DDL. https://help.branch.io/developers-hub/docs/ios-basic-integration , https://help.branch.io/developer-hub/docs/ios-advanced-features
Public API (Swift)See snippet below. Callback shape: (params: [AnyHashable: Any]?, error: Error?) — one raw params dictionary carries link data + attribution flags (+is_first_session, +clicked_branch_link, +match_guaranteed). https://help.branch.io/developer-hub/docs/ios-full-reference
DDL on iOSNativeLink™ pasteboard (100% match; must be enabled in dashboard; iOS 16 shows the paste prompt on first open) + probabilistic snapshot-matching fallback. DDL arrives through the same initSession callback as opens — no separate API. https://help.branch.io/docs/nativelink-deferred-deep-linking
SDK sizeNot published (open source, Swift/ObjC).
Min iOSiOS 12 (SPM changelog: "iOS 12 is now the min version", Xcode 15 min). Docs page still says iOS 9+ — stale. https://github.com/BranchMetrics/ios-branch-sdk-spm/blob/main/ChangeLog.md
DistributionSPM: https://github.com/BranchMetrics/ios-branch-sdk-spm (source; separate repo from the CocoaPods repo). CocoaPods: pod 'BranchSDK'. Carthage: yes.
Privacy manifestYes — PrivacyInfo.xcprivacy shipped. Known gotcha: CocoaPods without use_frameworks! can't merge multiple privacy manifests → documented build failure + manual-merge workaround. https://help.branch.io/developers-hub/docs/ios-basic-integration
import BranchSDK

func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
Branch.getInstance().initSession(launchOptions: launchOptions) { params, error in
// fires on install (deferred) AND every cold/warm open; params is one raw dictionary
if let click = params?["+clicked_branch_link"] as? Bool, click {
route(params ?? [:])
}
}
return true
}
// forward both entry points:
Branch.getInstance().application(app, open: url, options: options) // custom scheme
Branch.getInstance().continue(userActivity) // Universal Link

Notable extras: setRequestMetadataKey() must be called before init for partner metadata; deferInitForPlugin exists for RN/Unity but Branch explicitly warns not to use it for general deferral (verified in Branch.h). https://github.com/BranchMetrics/ios-branch-deep-linking-attribution/blob/master/Sources/BranchSDK/Public/Branch.h

2.2 AppsFlyer — iOS SDK V6/V7​

AspectFact
Integration shapeTwo-phase init: dev key + Apple App ID in didFinishLaunching, start() deferred to applicationDidBecomeActive (or ATT completion). Forward continue(userActivity:) and handleOpen(url). https://dev.appsflyer.com/hc/docs/integrate-ios-sdk
Public API (Swift)Legacy conversion data: AppsFlyerConversionListener → onConversionDataSuccess([AnyHashable: Any]) with is_first_launch; modern: Unified Deep Linking (UDL) — deepLinkDelegate + didResolveDeepLink(DeepLinkResult); result has status (.found/.notFound/.failure) + typed DeepLink object with isDeferred, deeplinkValue, clickEvent. https://dev.appsflyer.com/hc/docs/dl_ios_unified_deep_linking , https://dev.appsflyer.com/hc/docs/conversion-data-ios
DDL on iOSUDL fires on first open, matches install→click server-side; privacy masking: new users only get deep_link_value + deep_link_sub1-10 — media_source, af_sub1-5 etc. return null (existing users get everything). Legacy OneLinks route new users through onConversionDataSuccess ("eDDL", also extended for DMA consent). https://dev.appsflyer.com/hc/docs/dl_ios_unified_deep_linking
ATTwaitForATTUserAuthorization(timeoutInterval: 60) + manual ATTrackingManager.requestTrackingAuthorization in didBecomeActive. https://dev.appsflyer.com/hc/docs/integrate-ios-sdk
SDK sizeNot published; distributed as a closed static XCFramework.
Min iOSDocs say iOS 9+ (tvOS 9+, macOS 10.13) — the stalest minimum claim of the group. https://dev.appsflyer.com/hc/docs/ios-sdk
DistributionSPM: https://github.com/AppsFlyerSDK/AppsFlyerFramework — binaryTarget pointing at a zip'd static xcframework with a checksum (closed source, verified in Package.swift). CocoaPods: yes.
Privacy manifestYes — "Implement Privacy Manifest in your app" support doc; SDK checks ATT status before requests; tracking domains must be recorded in the manifest. https://support.appsflyer.com/hc/en-us/articles/21677433322641-Implement-Privacy-Manifest-in-your-app
// didFinishLaunchingWithOptions:
AppsFlyerLib.shared().appsFlyerDevKey = "<DEV_KEY>"
AppsFlyerLib.shared().appleAppID = "<APPLE_APP_ID>"
AppsFlyerLib.shared().deepLinkDelegate = self

// applicationDidBecomeActive (after ATT):
AppsFlyerLib.shared().start()

extension AppDelegate: DeepLinkDelegate {
func didResolveDeepLink(_ result: DeepLinkResult) {
switch result.status {
case .found:
if let dl = result.deepLink, dl.isDeferred == true { // install was attributed
route(dl.deeplinkValue, dl.clickEvent)
}
case .notFound: break // organic install
case .failure: print(result.error!)
@unknown default: break
}
}
}

2.3 Adjust — iOS SDK v5​

AspectFact
Integration shapeADJConfig(appToken:environment:) (sandbox/production), set delegate, Adjust.initSdk(config) in didFinishLaunching. Direct deep links: call Adjust.processDeeplink(AdjustDeeplink(url:)) from your open/continue handlers. https://dev.adjust.com/en/sdk/ios/features/deep-links/deferred/
Public API (Swift)The best-designed DDL hook of the big three: a delegate method that returns a Bool deciding whether the SDK opens the resolved link (true = SDK re-dispatches it through application(_:open:options:) / scene(_:openURLContexts:) / .onOpenURL, so the app's existing routing handles it; false = app keeps full control, e.g. wait for onboarding). Attribution is a separate typed AdjustAttribution object. https://dev.adjust.com/en/sdk/ios/features/deep-links/deferred/
DDL on iOSClick → store → install → first /session (+/attribution) network call → server matches click and returns the deferred link in the session response (optimized DDL; legacy used the attribution response). Server converts brandname.go.link URLs to the app's configured custom scheme (example://summer-clothes?promo=beach&adj_t=…) before delivery. https://dev.adjust.com/en/sdk/ios/features/deep-links/deferred/
SDK sizeNot published; source distribution, core self-contained ObjC.
Min iOSiOS 12 (v5 hard requirement — apps targeting lower must stay on v4). https://dev.adjust.com/en/sdk/migration/ios/v4-to-v5
DistributionSPM: github.com/adjust/ios_sdk — source target AdjustSdk (+ AdjustUnsigned variant without the signature lib), verified in Package.swift (platforms: [.iOS(.v12)]). CocoaPods Adjust pod, Carthage too.
Privacy manifestYes — changelog: "Added Privacy Manifest for the Adjust SDK"; also routes consented traffic to consent.adjust.com with payload restrictions. https://github.com/adjust/ios_sdk/blob/master/CHANGELOG.md
class AppDelegate: UIResponder, UIApplicationDelegate, AdjustDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: …) -> Bool {
let config = ADJConfig(appToken: "{token}", environment: ADJEnvironmentSandbox)
config?.delegate = self
Adjust.initSdk(config)
return true
}
// DDL: return false → you route manually; true → SDK opens it via the system
func adjustDeferredDeeplinkReceived(_ deeplink: URL?) -> Bool {
guard let deeplink else { return false }
storeForAfterOnboarding(deeplink) // e.g. stash until login done
return false
}
}

2.4 Singular — brief​

2.5 Kochava — brief​

2.6 Tenjin — brief (the lean end of MMPs)​

  • Init: TenjinSDK.getInstance("&lt;SDK_KEY>") every launch, then TenjinSDK.connect() — inside the ATT completion handler when ATT is used; accounts can be suspended if connect isn't called on every launch. https://raw.githubusercontent.com/tenjin/sdk-llm-guides/main/guides/ios/llm-guide.md
  • DDL: TenjinSDK.connectWithDeferredDeeplink(url) or registerDeepLinkHandler \{ params, error in } + connect().
  • Min iOS: 14. Distribution: SPM (tenjin/tenjin-ios-spm) + manual xcframework. Thin wrapper over a binary framework.
  • Shut down Aug 25, 2025. All FDL links (page.link + custom domains) now 404; Short Links & Link Stats APIs dead. https://firebase.google.com/support/dynamic-links-faq
  • Google's official recommendation: two-track — (a) installed-app deep linking → plain Universal Links (official migration guide, optionally AASA hosting via Firebase Hosting); (b) full feature parity incl. deferred → third-party vendors, named list Adjust, Airbridge, AppsFlyer, Bitly, Branch, Kochava, Singular ("not vetted by Google"). https://firebase.google.com/support/guides/app-links-universal-links , https://firebase.google.com/support/dynamic-links-faq
  • The gap: Apple still ships no first-party deferred deep linking on iOS, and Google's exit removed the only free first-party one. Migration guides describe the hole bluntly: DDL is the one FDL feature platform links can't replace ("handle deferred deep linking, or accept the loss"). https://link.boo/guides/firebase-dynamic-links-replacement
  • Consequence: a wave of FDL-replacement products (see §2.8) and vendor capture (AppsFlyer/Branch both published FDL-migration pitches). OptoLink's native SDKs are aimed exactly at this vacated "lightweight, self-hostable DDL" slot.

2.8 Modern lightweight / open-source entrants​

ProjectWhat it isiOS API shapeWhy it matters for OptoLink
LinkForty (MIT, self-hosted)Open-source Branch/FDL alternative: core server + SDKs; "privacy-first, no per-click pricing". iOS: 100% Swift, async/await API, zero dependencies, iOS 16+, SPM/CocoaPods/Carthage. DDL via "privacy-compliant fingerprinting", no IDFA. https://github.com/LinkForty/mobile-sdk-iostry await LinkForty.shared.initialize(config:); handleDeepLink(url:); onDeepLink \{ url, data in }; onDeferredDeepLink \{ data in } (nil = organic); trackEvent/trackRevenue async; SwiftUI .linkfortyScreen() modifierClosest existing template of the minimal modern shape — closures for link delivery, async for network ops, no ATT, config-object init, attributionWindowHours configurable
WarpLink (commercial, tiny)DDL-focused lightweight vendor. Two-call API: configure(apiKey:) + checkDeferredDeepLink exactly once on first launch. Match cascade: IDFV (conf 1.0) → timezone-enriched fingerprint tiers (0.85→0.20 with explicit confidence scores + matchGuaranteed), server computes hashes so iCloud Private Relay only degrades confidence, doesn't break matching; no ATT needed. https://warplink.app/blog/deferred-deep-linking-iosResult object: matchType, matchConfidence, matchGuaranteed — guidance: gate sensitive actions on guaranteed, route public content above ~0.5The exposed confidence/matchType in the public result is the honest version of Branch's opaque +match_guaranteed — worth copying
Flinku / LinkMe / DeepOne.ioSmall FDL-replacement SaaS entrants, all advertising "deferred deep linking, Universal Links, attribution" on GitHub topic deferred-deep-linking. https://github.com/topics/deferred-deep-linking , https://flinku.dev/ , https://r-dev-limited.github.io/li-nk.me-ios-sdk/Standard shape (init + handle + deferred callback)Proof the post-FDL slot is contested; differentiation is price/self-hosting, not API novelty

3. Cross-vendor comparison (iOS)​

BranchAppsFlyerAdjustSingularKochavaTenjinLinkForty
Min iOS12 (changelog; docs say 9, stale)9+ (docs, stale)12 (v5 hard floor)12 (Package.swift)15.5 (current)1416
SPM✅ source (separate repo)✅ but closed binary xcframework✅ source (+AdjustUnsigned)✅ closed binary xcframework✅ SPM-exclusive since v8 (binary)✅✅ source
CocoaPods✅ BranchSDK✅✅ Adjust✅❌ dropped in v8✅✅
DDL mechanismPasteboard NativeLink + fingerprintServer match via UDL/conversion (+ pasteboard variant)Server match in session responseServer match in link handler (10s timeout)Attribution response + null-URI queryconnectWithDeferredDeeplinkFingerprint, no IDFA
DDL API styleSame initSession callback as opensSeparate delegate (didResolveDeepLink), status enum + isDeferredDelegate returns Bool (open-or-not)One handler, isDeferred flag in paramsExplicit Deeplink.process(nil)Explicit deferred call + handlerDedicated onDeferredDeepLink closure (nil = organic)
Privacy manifest✅ (CocoaPods merge gotcha)✅ (+ tracking-domain rules)✅ (changelog-verified)✅✅ (guidance)(thin wrapper; not surfaced)n/a (self-hosted, MIT)
Source visible?✅ MIT❌ binary✅ MIT❌ binary❌ binary❌ binary✅ MIT

iOS deltas vs the Android research: (1) no deterministic OS referrer → every vendor leans on pasteboard/fingerprint, so DDL reliability is worse on iOS and matchConfidence-style honesty matters more; (2) ATT shapes init order (AppsFlyer/Tenjin) or is designed away entirely (lightweight vendors); (3) privacy manifests are the iOS analog of Android's Data-safety pain; (4) min-floor drift: Android converged on 21, iOS is diverging (12 → 14 → 15.5) because Swift concurrency and visionOS-era packaging push floors up — pick a floor deliberately, don't inherit one.


4. Criticisms & design signals specific to iOS​

  1. The pasteboard prompt tax. Branch documents that NativeLink on iOS 16+ shows the native paste prompt during first open; their docs now carry UX warnings. Any DDL design that silently reads UIPasteboard is one OS update from breaking. Lesson: make pasteboard matching opt-in and expect the prompt. https://help.branch.io/docs/nativelink-deferred-deep-linking
  2. Closed-binary SPM distribution is a trust regression. AppsFlyer and Singular "support SPM" only by shipping opaque static xcframeworks — unauditable (mirrors the Android hash-asset-blob complaint). Adjust ships readable source via SPM. Lesson: OptoLink SDK = source package, inspectable. (Verified in both Package.swift files.)
  3. Stale minimums and doc drift. Branch docs say iOS 9+, reality is iOS 12; AppsFlyer still claims iOS 9+ on its overview. Lesson: publish one machine-readable source of truth (Package.swift platforms) and keep marketing docs generated from it.
  4. AppDelegate/SceneDelegate split is the #1 integration bug farm. Every vendor docs the scene cold-start workaround separately; Branch ships a whole BranchScene compatibility class. Lesson: a single handleLaunch/handleDeepLink entry point that internally handles both worlds removes the app's biggest source of "link works cold but not warm" tickets.
  5. Kochava's reinstall replay. Attribution response re-delivers old deferred deeplinks on reinstall; apps must gate on is_first_install. Lesson: OptoLink should make install-vs-open explicit in the payload, and not replay expired promotions. https://support.kochava.com/articles/reference-information/15692-attribution-response-examples/
  6. AppsFlyer's new-user masking is well-intentioned but confusing — UDL silently nulls media_source/af_sub for new installs, generating "why is this key missing" tickets. Lesson: return a documented, complete payload with an explicit field (matchType/isNewUser) instead of per-key nulls. https://dev.appsflyer.com/hc/docs/dl_ios_unified_deep_linking
  7. ATT-wait timeouts are footguns. waitForATTUserAuthorization(60) can hold attribution up to a minute behind a prompt the user may never answer. Lesson: OptoLink's pipeline must work with zero ATT coupling.
  8. FDL's 404 apocalypse applies double on iOS. No platform fallback existed, so every FDL link on iOS is simply dead. Lesson (same as Android §5.9): links must live on the customer's own domain with AASA hosted from it.

API shape (recommendation)​

// 1. Init — config object, async or callback-free; once, in AppDelegate.didFinishLaunching
try OptoLink.configure(OptoLinkConfig(
apiKey: "…",
baseUrl: URL(string: "https://go.customer.com")!)) // customer's own domain, AASA hosted from it

// 2. Direct deep links — ONE entry point for every iOS surface
OptoLink.handleDeepLink(url) // scene(_:openURLContexts:), .onOpenURL
OptoLink.handleUserActivity(activity) // scene(_:continue:), cold-start connectionOptions
// internally emits: .direct(OptoLinkData) to the handler below

// 3. Delivery — a single async stream + closure sugar for UIKit apps
OptoLink.onLink { (link: OptoLinkResult) in … } // or: for await link in OptoLink.links
// OptoLinkResult: .direct(data) | .deferred(data, matchType, confidence) | .organic

// 4. Deferred — explicit, once, on first launch; ALSO fires through (3)
await OptoLink.resolveDeferredLink() // server match; returns .organic when nothing matches

Concretely:

  • Callbacks for delivery, async/await for actions. Deep-link delivery must work in didFinishLaunching before any UI exists → a registration-style handler (Branch/Adjust/Kochava model). Create-link, resolve, events → async throws (LinkForty/WarpLink generation). Offer AsyncStream as the modern delivery option, not the only one.
  • Typed result, status + isDeferred + matchType/confidence (AppsFlyer's DeepLinkResult enum + WarpLink's confidence, merged). No raw [String: Any] (Branch) and no silent per-key nulling (AppsFlyer).
  • Adjust-style shouldOpen hook for deferred links: return false, app routes after onboarding. This is the single best API decision in the market.
  • One launch handler covering AppDelegate + SceneDelegate + SwiftUI (kills the BranchScene class of problems).
  • isFirstLaunch/isReinstall explicit in the payload (Kochava's replay gotcha, done right).

Include​

  • Universal Links + custom scheme ingestion; AASA-hosting guidance for the customer domain (server-side).
  • Deferred DDL via server-side match on first session call (IDFV + click context, opt-in fingerprint tier with confidence), delivered through the same result type as opens.
  • Pasteboard matching as opt-in, documented with the iOS 16 prompt caveat.
  • PrivacyInfo.xcprivacy shipped in the package; zero required ATT coupling; optional AdServices (weak-linked) for Apple Search Ads.
  • SPM source distribution, min iOS 12–13 (adjustable constant), zero third-party dependencies, SwiftUI convenience modifier.

Skip​

  • IDFA/ATT plumbing, SKAdNetwork (not deep linking), geolocation/monetized data (FTC v. Kochava), any binary xcframework distribution, auto-swizzling of AppDelegate (explicit calls only — the one place to not copy Branch), auto-read of pasteboard by default, and any vendor-hosted link domain (that's the FDL failure mode).

Filed by the OptoLink iOS SDK research pass. Cross-references: android-sdk-competitive-research.md (platform patterns), ios-sdk-android-parity-notes.md, ios-sdk-contracts-notes.md.