Native iOS SDK for LinkForty — the open-source alternative to Branch.io, AppsFlyer OneLink, and Firebase Dynamic Links. Add deferred deep linking, mobile attribution, and smart link routing to your iOS app. Self-hosted, privacy-first, no per-click pricing. 100% Swift with modern async/await APIs.
- Deferred Deep Linking: Match app installs to link clicks using privacy-compliant fingerprinting
- Universal Links: Full support for iOS Universal Links (HTTPS deep links)
- Custom URL Schemes: Handle custom app URL schemes
- Event Tracking: Track in-app events and conversions
- Last-Click Attribution: In-app events are automatically credited to the deep link that most recently opened the app
- Screen-Flow Tracking: Report screen views to see what users do after clicking a link
- Offline Support: Events are kept on the device until the server has them, sent on the next launch or return to the foreground, and stored once however often they are retried
- Privacy-First: No IDFA, no App Tracking Transparency prompt, ships a privacy manifest
- Programmatic Link Creation: Create short links directly from your app
- Zero Dependencies: Lightweight, no third-party dependencies
- Swift-Native: 100% Swift, modern async/await APIs
- iOS 16.0+
- Xcode 15.0+
- Swift 5.9+
The SDK is distributed with Swift Package Manager only. There is no CocoaPods pod or Carthage build.
Add the following to your Package.swift file:
dependencies: [
.package(url: "https://github.com/LinkForty/mobile-sdk-ios.git", from: "1.8.0")
]Or in Xcode:
- File > Add Package Dependencies
- Enter:
https://github.com/LinkForty/mobile-sdk-ios.git - Select version and add the
LinkFortySDKlibrary to your app target
Initialize once at launch, in your AppDelegate or @main App struct:
import LinkFortySDK
// In AppDelegate
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
Task {
do {
let config = LinkFortyConfig(
baseURL: URL(string: "https://api.linkforty.com")!, // or your self-hosted server
appToken: "at_a1b2c3d4...", // Required on the hosted service
debug: true
)
try await LinkForty.shared.initialize(config: config)
} catch {
print("LinkForty initialization failed: \(error)")
}
}
return true
}LinkFortyConfig fields:
| Field | Default | Description |
|---|---|---|
baseURL |
(required) | The LinkForty API server: https://api.linkforty.com on the hosted service, or your own server's URL. Must be HTTPS (HTTP is allowed only for localhost). |
appToken |
nil |
Your workspace's public app token (at_...). Required on the hosted service: it limits install matching and link lookups to your workspace's links and lets organic installs be counted. Safe to ship in the app bundle. A self-hosted Core server does not use it. |
apiKey |
nil |
Only needed for createLink. It is a secret: leave it out unless the app creates links. |
debug |
false |
Logs requests and SDK decisions to the console. |
attributionWindowHours |
168 (7 days) |
How far back an install may be matched to a click, 1 to 2160 hours. |
If the install can't be reported (for example, the device is offline on first launch), initialize throws and leaves the SDK uninitialized, so you can call it again later. Register onDeferredDeepLink and onDeepLink before or after initialize; callbacks registered first are kept.
LinkForty.shared.onDeferredDeepLink { deepLinkData in
if let data = deepLinkData {
// User installed from a link - navigate to content
print("Install attributed to: \(data.shortCode)")
print("UTM Source: \(data.utmParameters?.source ?? "none")")
// Navigate to the right content
if let productId = data.customParameters?["productId"] {
navigateToProduct(id: productId)
}
} else {
// Organic install - no attribution
print("Organic install")
}
}A deferred deep link also says how sure the match is: confidenceScore (0-100), matchedFactors, attributionMethod and competingLinks. competingLinks counts the other links that had a click from the same network and also reached the confidence threshold. At 0 nothing else was in contention; above 0, another device's tap may be what led to this install, so before acting on a sensitive link (a sign-in or a gift, say) treat the match as unverified, for example by asking the user to confirm:
LinkForty.shared.onDeferredDeepLink { deepLinkData in
guard let data = deepLinkData else { return }
if (data.competingLinks ?? 0) > 0 {
askUserToConfirm(data) // another link was in contention
} else {
redeem(data)
}
}These fields are set only on a deferred deep link from a server that sends them; they are nil on a direct open.
First, enable Associated Domains in your Xcode project:
- Select your target > Signing & Capabilities
- Add "Associated Domains"
- Add one entry per host your links are served on, for example
applinks:go.yourdomain.com. This is the link domain in your short links, not the APIbaseURL. See Universal Links Setup.
The SDK does not observe incoming links by itself: the app passes each URL to handleDeepLink(url:). Where iOS delivers the URL depends on your app's lifecycle, so wire every entry point your app uses.
SwiftUI (App lifecycle): .onOpenURL receives both Universal Links and custom-scheme URLs, including the one that launched the app:
WindowGroup {
ContentView()
.onOpenURL { url in
LinkForty.shared.handleDeepLink(url: url)
}
}If the app also has an .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) handler, Universal Links go there instead of .onOpenURL: pass userActivity.webpageURL to handleDeepLink from it.
UIKit with a SceneDelegate: links that launch the app arrive in scene(_:willConnectTo:options:); links that arrive while it runs come through scene(_:continue:) (Universal Links) and scene(_:openURLContexts:) (custom schemes). The AppDelegate methods below are not called in a scene-based app.
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// ... your window setup ...
if let url = connectionOptions.userActivities.first(where: { $0.activityType == NSUserActivityTypeBrowsingWeb })?.webpageURL {
LinkForty.shared.handleDeepLink(url: url)
}
if let url = connectionOptions.urlContexts.first?.url {
LinkForty.shared.handleDeepLink(url: url)
}
}
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL else { return }
LinkForty.shared.handleDeepLink(url: url)
}
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
if let url = URLContexts.first?.url {
LinkForty.shared.handleDeepLink(url: url)
}
}UIKit without scenes (AppDelegate only):
// Universal Links
func application(_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL else {
return false
}
LinkForty.shared.handleDeepLink(url: url)
return true
}
// Custom URL schemes
func application(_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
LinkForty.shared.handleDeepLink(url: url)
return true
}A URL passed in before initialize finishes (as when a link launches the app) is held and handled once the SDK is initialized.
Then register a callback to receive the link data:
LinkForty.shared.onDeepLink { url, deepLinkData in
print("Deep link opened: \(url)")
if let data = deepLinkData {
print("Link data: \(data)")
// Navigate using deep link path
if let path = data.deepLinkPath {
navigateToPath(path)
}
}
}Server-side resolution: Pass every URL the app receives to
handleDeepLink. Anhttp(s)URL on thebaseURLhost or on one of your workspace's link hosts is resolved via the server to provide enriched data includingdeepLinkPath,appScheme, andlinkId; any other URL, custom schemes included, reachesonDeepLinkas a local parse without a request. The SDK learns the link hosts from the server whenappTokenis set; until they are known, and without anappToken, everyhttp(s)URL is resolved. If the server is unreachable, the SDK falls back to local URL parsing.
// Track a simple event
try await LinkForty.shared.trackEvent(name: "button_clicked")
// Track event with properties
try await LinkForty.shared.trackEvent(
name: "purchase",
properties: [
"product_id": "123",
"amount": 29.99,
"currency": "USD"
]
)
// Track revenue
try await LinkForty.shared.trackRevenue(
amount: 29.99,
currency: "USD",
properties: ["product_id": "123"]
)Every event is automatically stamped with the deep link that most recently opened the app (last-click attribution), so the dashboard can show what users do after clicking a link. Events with no preceding deep-link open are reported as organic. No extra code is required.
Reporting screen views lets the dashboard build a per-link screen-flow funnel — which screens users reach after opening a deep link. Each screen_view carries the same last-click attribution stamp as other events.
SwiftUI — add the .linkfortyScreen(_:) modifier to a screen:
ProductView()
.linkfortyScreen("ProductDetail")UIKit — call from viewDidAppear:
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
Task { try? await LinkForty.shared.trackScreenView(name: "ProductDetail") }
}There is no navigation swizzling: you mark the screens you care about, which keeps screen names meaningful and predictable. The SDK records the previous screen automatically so transitions appear in the funnel.
let result = try await LinkForty.shared.createLink(
options: CreateLinkOptions(
deepLinkParameters: ["route": "VIDEO_VIEWER", "id": "vid123"],
title: "Check this out!",
utmParameters: UTMParameters(source: "app", campaign: "share")
)
)
print("Share this link: \(result.url)")
// e.g., "https://go.yourdomain.com/tmpl/abc123"Note: Requires an
apiKeyinLinkFortyConfig(without one,createLinkthrowsLinkFortyError.missingApiKey) and a server that provides the SDK link endpointPOST /api/sdk/v1/links, as the hosted service does. A self-hosted Core server does not have that endpoint, and itsPOST /api/links, which the SDK falls back to, requires anoriginalUrlthe SDK does not send, socreateLinkdoes not work against Core. See API Reference for all options.
If you're running your own LinkForty Core instance, point baseURL at it. Core does not use an appToken, and no apiKey is needed (createLink is not available against Core):
let config = LinkFortyConfig(
baseURL: URL(string: "https://links.yourcompany.com")!,
debug: false
)
try await LinkForty.shared.initialize(config: config)let config = LinkFortyConfig(
baseURL: URL(string: "https://api.linkforty.com")!,
appToken: "at_a1b2c3d4...",
attributionWindowHours: 24 // 1 day instead of default 7 days
)if let installData = LinkForty.shared.getInstallData() {
print("Short code: \(installData.shortCode)")
print("UTM source: \(installData.utmParameters?.source ?? "none")")
}
if let installId = LinkForty.shared.getInstallId() {
print("Install ID: \(installId)")
}An event that can't be sent because of a network error or a server error (5xx) is kept on the device and sent later; trackEvent returns normally in that case. Each request is tried up to 3 times (with 1 s and 2 s pauses) before the event is left queued. The queue holds up to 100 events and survives app restarts; it is sent after initialize, when the app returns to the foreground, after the next event that sends, or when you call flushEvents(). When the queue is full, a new event that can't be sent is dropped without an error. An event the server rejects (4xx) throws and is not queued.
// Check queued events count
let count = LinkForty.shared.queuedEventCount
// Manually flush event queue
await LinkForty.shared.flushEvents()
// Clear event queue
LinkForty.shared.clearEventQueue()LinkForty.shared.clearData()
// Reset SDK to uninitialized state
LinkForty.shared.reset()Every host your links are served on must serve an Apple App Site Association file at:
https://go.yourdomain.com/.well-known/apple-app-site-association
You don't upload this file: the LinkForty server generates it. On the hosted service it is served once your Apple Team ID and bundle ID are set in your app configuration. A self-hosted Core server serves it when the IOS_TEAM_ID and IOS_BUNDLE_ID environment variables are set, and answers 404 otherwise.
The generated file looks like this:
{
"applinks": {
"apps": [],
"details": [{
"appID": "TEAM_ID.com.yourcompany.yourapp",
"paths": ["*"]
}]
}
}- Enable "Associated Domains" capability
- Add
applinks:<host>for each host your links are served on, e.g.applinks:go.yourdomain.com - Pass incoming links to
handleDeepLinkfrom every entry point your app uses (see Handle Direct Deep Links)
Use Apple's validation tool:
Or test manually:
- Create a link in LinkForty
- Open link in Safari on device
- Long press the link
- Verify "Open in YourApp" appears
- No IDFA: Does not read the Identifier for Advertisers, does not show an App Tracking Transparency prompt, and does not use SKAdNetwork
- Probabilistic install matching: A deferred deep link is matched to a click from device signals and the network, not from a device ID
- Install ID: The server assigns an install ID on the first launch. The SDK stores it on the device (in
UserDefaults) and sends it with every event, so events are tied to that install - User Control: Provides
clearData()to delete the data the SDK stores on the device - Privacy Manifest: Includes
PrivacyInfo.xcprivacy(declares no tracking)
When the install is reported:
- Device timezone and language
- Screen resolution (in pixels)
- iOS version and app version
- A user-agent string made from the app name, app version and iOS version
- The
appToken, and the SDK name and version - A
deviceId, only if your app passes one toinitialize(config:attributionWindowHours:deviceId:)
When a link is resolved on the server: timezone, language, screen resolution and iOS version.
With each event: the event name and the properties your app passes, the install ID, a session ID (new on each launch and each deep link open), and the ID of the link that last opened the app and when it opened (if any).
As with any network request, the server also sees the device's IP address.
The SDK enforces HTTPS for all API endpoints (except localhost for testing).
swift testOr in Xcode:
Cmd+U to run all tests
See E2E/README.md for the simulator tests that run the SDK against a real LinkForty server.
- Basic Example - Simple SwiftUI app demonstrating all SDK features
This SDK requires a running LinkForty backend:
- LinkForty Core (open source): Self-host for free
- The hosted LinkForty service (linkforty.com): managed, nothing to run
See: https://github.com/LinkForty/core
- Documentation: docs.linkforty.com
- Issues: GitHub Issues
- Discussions: GitHub Discussions
Contributions are welcome! Please see CONTRIBUTING.md for guidelines.
See CHANGELOG.md for version history.
LinkForty iOS SDK is available under the MIT license. See LICENSE for more info.
| Platform | Package |
|---|---|
| React Native | @linkforty/mobile-sdk-react-native |
| Expo | @linkforty/mobile-sdk-expo |
| Android (Kotlin) | LinkFortySDK |
| Flutter | linkforty_flutter |
- LinkForty Core — open-source self-hosted deep linking engine
- LinkForty — the hosted service, with a dashboard