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.

Prerequisites

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.

  1. 1Register the app — Apps → Register app, with your Apple Team ID and bundle ID.
  2. 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 (onError reports it as a server error).
  3. 3Add a link domain — Domains. Or use your free *.linktrail.io subdomain.
  4. 4Create an API key — Settings → API Keys. That is the lt_live_… value below.
  1. 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

    PodfileRuby
    pod 'LinkTrailSDK', '~> 0.1.0'

    Then run pod install and open the generated .xcworkspace.

  2. Integrate

    App.swiftSwift
    import 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 */ }

    customData is a map of strings to strings. A nested object or array set on the link arrives as a compact JSON string — decode it yourself.

    Note

    LinkTrail.configure is 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: register onLink, onAttribution and onError again on it, and use LinkTrail.shared rather than a stored reference, because every call on a replaced instance is ignored. Saved state (install flags, consent, device ID) is kept.

  3. 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.

    Important

    An app that never calls setConsent gets no deferred link and no attribution. Wire both answers of your consent prompt to setConsent, or pass requireConsent: false if 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))
  4. 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
        }
    }

    handleDeepLink returns 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 await LinkTrail.shared?.handleDeepLink(url) returns the resolved link). Callbacks are delivered on the main thread.

  5. 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 are https, so share those — and handleDeepLink returns false for a custom-scheme URL. Target → Info → URL Types → +, or in Info.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 NSUserTrackingUsageDescription only if your app calls requestTrackingAuthorization() — 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

    1. 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.
    2. 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.io fetches it the way the system does.
    3. 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.)
    4. 4Long-press a link in Notes or Messages. Open in “YourApp” in the menu means the association works; a tap then opens the app.
    Warning

    The 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.

    Warning

    handleDeepLink claims only https links on linktrail.io (or a subdomain) and on the hosts listed in linkDomains (or their subdomains). Custom domains must be listed: links on go.yourbrand.com need LinkTrailOptions(linkDomains: ["go.yourbrand.com"]), or the app opens and never navigates. Any other URL (mailto:, an OAuth callback, myapp://settings) returns false (the async form throws notALinkTrailURL) with no request and no onError, so route it yourself. Custom-scheme LinkTrail links (myapp://slug) are no longer handled; use the https link. The full rules: Which links the SDK claims. Upgrading from an earlier version? See Upgrading to 0.1.0.

  6. Verify it works

    Every failure on this page is silent unless you look in the right place. Check these in order:

    1. 1SDK logs. Pass LinkTrailOptions(logEnabled: true, logLevel: .debug) to configure and read the Xcode console — every line is prefixed [LinkTrail]. On the first launch expect Install held — awaiting consent decision; after setConsent(true) expect Consent granted, Sending install event… (consent: true), then Install attributed — Install attributed (deterministic click token) when the click token was recovered — or Organic 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 means configure never ran.
    2. 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.
    3. 3Overview and Links. Dashboard → Overview: the installs count goes up. Links → your link shows a click and an install.
    4. 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 through onError as LinkTrailError.invalidAPIKey — but only once the install is sent: after setConsent, or at configure with requireConsent: false. Nothing is sent before that.
    5. 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.

More

Swift
// 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)
Warning

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.

clickTokenSourceBehaviourTrade-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.
.automaticThe SDK reads the clipboard itself at install.No UI, but iOS shows the “Allow Paste” alert on first launch.
.noneThe 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.
Warning

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.

Important

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().

Swift
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 fieldDefaultNotes
requireConsenttrueDeny-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
autoTrackInstalltruefalse = the SDK never sends the install on its own: the paste button or your trackInstall() call does
logEnabledfalseMaster switch for SDK logging
logLevel.info.debug · .info · .warning · .error · .none
requestTimeout15 sPer-request network timeout
retryPolicy.default3 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.pasteButtonHow the deferred click token is read on first launch (iOS paste control)

API surface

APIPurpose
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 / lastDeepLinkCached 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.

Note

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.