iOS SDK
Install and integrate the LinkTrail iOS SDK — module LinkTrailSDK, iOS 15+, via Swift Package Manager or CocoaPods — with configure, onLink and consent.
Binary XCFramework · module LinkTrailSDK · entry point LinkTrail · iOS 15+, Swift 5.9 / Xcode 15+ · Swift Package Manager or CocoaPods.
Before this works, set up three things in the dashboard. Skip one and links fail silently — they open the browser instead of your app. Full flow: Quickstart.
- 1Register the app — Apps → Register app, with your Apple Team ID and bundle ID.
- 2Register the identifier your build really has — the SDK sends your app's bundle ID (
Bundle.main.bundleIdentifier), and it must equal the identifier you registered, in the same workspace as the API key. A debug build with a suffixed ID (com.acme.shop.debug) needs its own registered app, or every install answers 404 (onErrorreports it as a server error). - 3Add a link domain — Domains. Or use your free
*.linktrail.iosubdomain. - 4Create an API key — Settings → API Keys. That is the
lt_live_…value below.
Install
Swift Package Manager
In Xcode: File → Add Package Dependencies… → paste the repo URL → pick a version. Or in a
Package.swift:Package.swiftSwift.package(url: "https://github.com/linktrail-io/ios-sdk.git", from: "0.1.0")CocoaPods
PodfileRubypod 'LinkTrailSDK', '~> 0.1.0'Then run
pod installand open the generated .xcworkspace.Integrate
App.swiftSwiftimport LinkTrailSDK // At launch (SwiftUI App.init or AppDelegate). The API key is required — configure throws. // With the default consent gate the install is held here until setConsent (see Consent). try LinkTrail.configure(apiKey: "lt_live_...") // One hook — fires for deferred (first-launch) AND re-engagement links: LinkTrail.shared?.onLink { link, source in router.route(to: link.path, customData: link.customData) } LinkTrail.shared?.onError { error in /* e.g. LinkTrailError.invalidAPIKey */ }customDatais a map of strings to strings. A nested object or array set on the link arrives as a compact JSON string — decode it yourself.NoteLinkTrail.configureis a singleton. Calling it again with the same API key and equal options is a no-op that returns the existing instance. A call with a different key or options shuts the previous instance down (its pending work is cancelled and its callbacks are dropped) and returns a new one: registeronLink,onAttributionandonErroragain on it, and useLinkTrail.sharedrather than a stored reference, because every call on a replaced instance is ignored. Saved state (install flags, consent, device ID) is kept.Consent
Consent gating is on by default (
requireConsent: true) and is deny-by-default. Until your app records a decision, nothing leaves the device: no install, no clipboard read, no device identifier. The decision releases the held install either way —setConsent(true)sends it counted and attributed and routes the deferred link;setConsent(false)sends one uncounted install (no device id) that still resolves the deferred link, so the user lands in the right place. Re-engagement links (app already installed) route regardless; consent only decides whether the open is recorded. The decision is persisted across launches, and there is no getter — keep your own copy if your UI needs it. Cross-platform detail, CMP integration and the store questionnaires: Consent & privacy.ImportantAn app that never calls
setConsentgets no deferred link and no attribution. Wire both answers of your consent prompt tosetConsent, or passrequireConsent: falseif consent is collected elsewhere.Swift// Grant → sends the held install (counted, attributed), routes the deferred link // and flushes the offline event queue: LinkTrail.shared?.setConsent(true) // Deny → sends one uncounted install with no device id; the deferred link still // routes, nothing is recorded, queued events are dropped: LinkTrail.shared?.setConsent(false) // Collecting consent elsewhere (a CMP)? Opt out of the gate — tracking runs from configure, // and an explicit setConsent(false) still wins: try LinkTrail.configure(apiKey: "lt_live_...", options: LinkTrailOptions(requireConsent: false))Forward incoming links
Forward incoming links — SwiftUI:
Swift.onOpenURL { LinkTrail.shared?.handleDeepLink($0) } .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in if let url = activity.webpageURL { LinkTrail.shared?.handleDeepLink(url) } }Or UIKit, from your SceneDelegate:
SceneDelegate.swiftSwift// Universal Links (app already running or cold-started from a link): func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { if let url = userActivity.webpageURL { LinkTrail.shared?.handleDeepLink(url) } } // Your own custom-scheme links (yourapp://…) are not LinkTrail links: // handleDeepLink returns false for them, so route them yourself. func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) { for context in URLContexts where LinkTrail.shared?.handleDeepLink(context.url) != true { // your own routing for context.url } }handleDeepLinkreturns false for URLs that aren't LinkTrail links, so you can fall through to your own routing. Every callback API also has an async throws twin (e.g. try awaitLinkTrail.shared?.handleDeepLink(url)returns the resolved link). Callbacks are delivered on the main thread.Xcode setup
Associated Domains
Select your app target → Signing & Capabilities → + Capability → Associated Domains, then add
applinks:yourapp.linktrail.io— one entry per link domain, custom domains included. Xcode writes it to the target's entitlements file:YourApp.entitlementsXML<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>com.apple.developer.associated-domains</key> <array> <string>applinks:yourapp.linktrail.io</string> </array> </dict> </plist>Custom URL scheme (optional)
Only if your app has its own
yourapp://links. The SDK doesn't claim them — LinkTrail links arehttps, so share those — andhandleDeepLinkreturnsfalsefor a custom-scheme URL. Target → Info → URL Types → +, or inInfo.plist:Info.plistXML<key>CFBundleURLTypes</key> <array> <dict> <key>CFBundleURLName</key> <string>com.acme.shop</string> <key>CFBundleURLSchemes</key> <array> <string>yourapp</string> </array> </dict> </array>Tracking permission (only with ATT)
The SDK needs no tracking permission. Add
NSUserTrackingUsageDescriptiononly if your app callsrequestTrackingAuthorization()— without it the prompt never appears. See SKAdNetwork & ATT.Info.plistXML<key>NSUserTrackingUsageDescription</key> <string>We use this to measure which ads brought you to the app.</string>Verify Universal Links
- 1Check the file LinkTrail serves for your domain lists
<TeamID>.<bundle ID>:curl -s https://yourapp.linktrail.io/.well-known/apple-app-site-association, or run the free Universal Links validator. - 2Devices read the file from Apple's CDN, not from your domain. Check what Apple has cached:
curl -s https://app-site-association.cdn-apple.com/a/v1/yourapp.linktrail.io. On a Mac,sudo swcutil dl -d yourapp.linktrail.iofetches it the way the system does. - 3On the device, open Settings → Developer → Universal Links → Diagnostics and enter a link URL: it reports whether an installed app claims it. (The Developer menu appears once the device has been used for development from Xcode.)
- 4Long-press a link in Notes or Messages. Open in “YourApp” in the menu means the association works; a tap then opens the app.
WarningThe AASA file is generated and hosted by LinkTrail — it's built from the Team ID + bundle ID you registered in the dashboard. Nothing to upload.
WarninghandleDeepLinkclaims onlyhttpslinks onlinktrail.io(or a subdomain) and on the hosts listed inlinkDomains(or their subdomains). Custom domains must be listed: links ongo.yourbrand.comneedLinkTrailOptions(linkDomains: ["go.yourbrand.com"]), or the app opens and never navigates. Any other URL (mailto:, an OAuth callback,myapp://settings) returnsfalse(the async form throwsnotALinkTrailURL) with no request and noonError, so route it yourself. Custom-scheme LinkTrail links (myapp://slug) are no longer handled; use thehttpslink. The full rules: Which links the SDK claims. Upgrading from an earlier version? See Upgrading to 0.1.0.- 1Check the file LinkTrail serves for your domain lists
Verify it works
Every failure on this page is silent unless you look in the right place. Check these in order:
- 1SDK logs. Pass
LinkTrailOptions(logEnabled: true, logLevel: .debug)toconfigureand read the Xcode console — every line is prefixed[LinkTrail]. On the first launch expectInstall held — awaiting consent decision; aftersetConsent(true)expectConsent granted,Sending install event… (consent: true), thenInstall attributed—Install attributed (deterministic click token)when the click token was recovered — orOrganic install (no attribution match). Both platforms print[LinkTrail] [LEVEL] message, so these lines read[LinkTrail] [INFO] …on iOS and Android (Android logs them under the LinkTrail tag). No lines at all meansconfigurenever ran. - 2API key Last used. Dashboard → Settings → API Keys: the key's Last used changes from Never after the first call made with a valid key.
- 3Overview and Links. Dashboard → Overview: the installs count goes up. Links → your link shows a click and an install.
- 4onError. A wrong or revoked key does not fail
configure. The backend answers 401 with{"code":401,"message":"Invalid or missing API key"}and the SDK reports it throughonErrorasLinkTrailError.invalidAPIKey— but only once the install is sent: aftersetConsent, or atconfigurewithrequireConsent: false. Nothing is sent before that. - 5Re-test deferred without reinstalling. Call
LinkTrail.resetForTesting()— it clears the install flag, cached attribution, device ID and the consent decision, so the next launch behaves like a genuine first launch and asks for consent again. Testing only; never ship it.
[LinkTrail] [INFO] Install held — awaiting consent decision [LinkTrail] [INFO] Consent granted [LinkTrail] [INFO] Sending install event… (consent: true) [LinkTrail] [INFO] Install attributed (deterministic click token) [LinkTrail] [INFO] Install attributed [LinkTrail] [INFO] Organic install (no attribution match)Still nothing? Troubleshooting lists the usual causes by symptom.
- 1SDK logs. Pass
More
// Custom + revenue events:
LinkTrail.shared?.trackEvent(name: "purchase", value: 59.99, currency: "USD")
// Cached results:
let attribution = LinkTrail.shared?.lastAttribution
let lastLink = LinkTrail.shared?.lastDeepLink
// Attribution hook (fires when an install is attributed):
LinkTrail.shared?.onAttribution { attribution in /* … */ }
// Manual install (rare): autoTrackInstall: false, then call trackInstall() yourself.
// Still subject to the consent gate — a call made before the decision is held, and
// setConsent sends it:
let lt = try LinkTrail.configure(apiKey: "lt_live_...",
options: LinkTrailOptions(autoTrackInstall: false))
lt.trackInstall()
// ATT / SKAdNetwork:
await LinkTrail.shared?.requestTrackingAuthorization()
LinkTrail.shared?.registerForSKAdAttribution()
LinkTrail.shared?.updateConversionValue(42, coarseValue: .medium)Calling requestTrackingAuthorization() requires NSUserTrackingUsageDescription in your Info.plist — without it the ATT prompt never appears and authorization is denied.
Deferred attribution & the click token
Android gets deferred attribution from the Play Install Referrer, which the store passes through automatically. iOS has no equivalent, so LinkTrail carries a click token on the clipboard: the tapped link leaves it there and the SDK recovers it on first launch. clickTokenSource controls how that read happens.
| clickTokenSource | Behaviour | Trade-off |
|---|---|---|
| .pasteButton (default) | The token is read only when the user taps a paste control you render — LinkTrailPasteButton, shipped with the SDK. | No system alert, but it needs UI (iOS 16+) and the ordering rule below. |
| .automatic | The SDK reads the clipboard itself at install. | No UI, but iOS shows the “Allow Paste” alert on first launch. |
| .none | The clipboard is never touched, by any path — trackInstall(clickToken:) ignores a token handed to it. | No alert and no UI; deferred matching falls back to probabilistic only. |
The default is .pasteButton, so on iOS no click token is recovered until you render a paste control, and deferred installs fall back to probabilistic matching or arrive unattributed. If deferred installs arrive unattributed on iOS with everything else wired correctly, this is almost always why. Use .automatic to accept the Allow Paste alert instead, or .none to stay off the clipboard entirely.
Storing the value as a LinkTrailClickTokenSource?? Write LinkTrailClickTokenSource.none there — a bare .none means Optional.none (nil), not this case.
The ordering rule for `.pasteButton`: the tap has to be what sends the install, because an install that went out without the token is not sent again with it. So set autoTrackInstall: false, or make sure your app does not call setConsent(true) before the user has had the chance to tap — with auto-tracking on, that call releases the held install without the token and the token is wasted. With requireConsent: false, auto-tracking sends the install at configure, so there autoTrackInstall: false is the only option. And with autoTrackInstall: false, a user who never taps records no install until your app calls trackInstall().
import LinkTrailSDK
import SwiftUI
// The install waits for the tap, so don't auto-track it.
try LinkTrail.configure(
apiKey: "lt_live_...",
options: LinkTrailOptions(autoTrackInstall: false,
clickTokenSource: .pasteButton)
)
// On your first-launch screen, render the SDK's paste control. The system control
// reads the token with no "Allow Paste" alert. By default (grantsConsent omitted) the
// tap grants consent only while it is still undecided — an earlier setConsent(false)
// stays — then the SDK sends the install with the token and onLink routes the
// recovered destination. grantsConsent: true always grants (an explicit opt-in UI).
if #available(iOS 16.0, *) {
LinkTrailPasteButton()
}
// Using a CMP? The tap must not touch consent: the token is read and held, and the
// install waits for your setConsent decision.
if #available(iOS 16.0, *) {
LinkTrailPasteButton(grantsConsent: false)
}LinkTrailPasteButton wraps Apple’s UIPasteControl, which is iOS 16+. On iOS 15 there is no paste control at all, so use .automatic or .none there. React Native and Flutter ship the same control, and it goes through this native paste path: the tap records consent per grantsConsent and sends the install itself, so don't add a setConsent or trackInstallWithClickToken call to its callback. See LinkTrailPasteButton on those pages.
Rolling your own UIPasteControl instead? Its target must be a UIResponder that sets pasteConfiguration and overrides paste(itemProviders:) — without the pasteConfiguration the control stays disabled and never delivers. Hand the pasted string to LinkTrail.shared?.trackInstall(clickToken:). Record consent first — setConsent(true), then trackInstall(clickToken:) — with autoTrackInstall: false, per the ordering rule above.
Options & defaults
| LinkTrailOptions field | Default | Notes |
|---|---|---|
| requireConsent | true | Deny-by-default: the install is held until setConsent(true/false), then sent and the deferred link routes (grant attributes, deny routes only); re-engagement routes regardless |
| autoTrackInstall | true | false = the SDK never sends the install on its own: the paste button or your trackInstall() call does |
| logEnabled | false | Master switch for SDK logging |
| logLevel | .info | .debug · .info · .warning · .error · .none |
| requestTimeout | 15 s | Per-request network timeout |
| retryPolicy | .default | 3 attempts, 0.5 s base / 8 s max backoff; .disabled turns retries off |
| linkDomains | [] (linktrail.io only) | Extra link hosts to claim — list every custom domain; see the warning under Xcode setup |
| clickTokenSource | .pasteButton | How the deferred click token is read on first launch (iOS paste control) |
API surface
| API | Purpose |
|---|---|
| configure(apiKey:options:) | Initialize; the install is held until a setConsent decision (re-engagement routes regardless) |
| setConsent(granted) | Record the decision; releases the held install either way (see Consent above) |
| onLink { link, source } | Deep link delivered (deferred + re-engagement) |
| onAttribution { attribution } | Install attribution result |
| onError { error } | Failures (invalid key, network…) |
| handleDeepLink(url) | Forward incoming URLs; true only for a LinkTrail link (https, claimed host, slug) |
| trackEvent(name:value:currency:) | Custom + revenue events |
| trackInstall(force:) | Manual install track (with autoTrackInstall: false) |
| trackInstall(clickToken:force:) | Install carrying a click token your own paste control read (ignored under .none) |
| lastAttribution / lastDeepLink | Cached last results |
| requestTrackingAuthorization() | ATT prompt wrapper |
| registerForSKAdAttribution() / updateConversionValue(_:coarseValue:) | SKAdNetwork |
| LinkTrail.resetForTesting() | Static. Clears the install flag, attribution, device ID and consent — testing only |
App Store privacy
The SDK ships its own privacy manifest (PrivacyInfo.xcprivacy), so Xcode's privacy report includes it automatically. What it declares: a per-install device identifier — a random, app-scoped UUID that resets on uninstall (no IDFA, no IDFV, no system identifier at all), kept in a device-only file that is excluded from iCloud and device backups — linked to the user, used for Analytics and App Functionality; no cross-company tracking (NSPrivacyTracking = false). In App Store Connect's App Privacy questionnaire, count LinkTrail as: Identifiers → Device ID (the SDK's own UUID), linked to the user, not used for tracking.
Example app: KickFlip — a SwiftUI storefront in the ios-sdk repo's example folder, wired end to end. It needs XcodeGen: cd example, then LINKTRAIL_KEY=lt_live_… xcodegen generate (the key becomes the LINKTRAIL_API_KEY build setting; without a real one the backend answers 401), then open KickFlipDemo.xcodeproj. Source · example app · issue tracker · changelog.
Step-by-step walkthrough, including the paste control and testing on a clean install: How to set up deferred deep linking on iOS.