# Deferred Deep Linking on iOS: How to Implement It in Swift

> Deferred deep linking iOS guide: match the first launch after an App Store install in Swift, with confidence routing, IDFV limits, and tests. No IDFA prompt.

_Last updated: 2026-10-26_

Deferred deep linking on iOS sends a user who taps a link without your app through the App Store and onto the right screen on first launch. iOS has no install referrer, so the App Store passes nothing to the new app. The server matches the first launch back to the recorded tap with probabilistic matching on the request address, language, and timezone, and returns a confidence score. In Swift it is one `configure()` call.

```swift
import SwiftUI
import WarpLink

@main
struct MyApp: App {
    init() {
        WarpLink.configure(
            apiKey: "wl_live_yoursdkkeyhere000000000000000000",
            options: WarpLinkOptions(onLink: { result in
                // A tapped link, or a deferred match after the install.
                Task { @MainActor in Router.shared.handle(result) }
            })
        )
    }

    var body: some Scene {
        WindowGroup { ContentView() }
    }
}
```

That is the whole integration for the deferred path. `configure()` runs the first-launch check by itself, once per install, and needs no IDFA and no App Tracking Transparency prompt. The rest of this guide covers what happens on the wire, how to route on the confidence score, and how to test a real first install. For the cross-platform overview, see [deferred deep linking](https://warplink.app/deferred-deep-linking). For how it differs from a standard link, see [deep link vs deferred deep link](https://warplink.app/blog/deep-link-vs-deferred-deep-link).

## What Happens After an iOS Install?

Universal Links solve one problem cleanly: the user has your app, taps a link, and iOS hands the URL to the app. When the app is not installed, that chain breaks. The user taps, lands on the App Store, installs, opens the app, and arrives on the home screen with no idea why they came. A developer on the Apple Developer Forums described the symptom exactly: "when the user installs the app after clicking the link, the link does not always open the intended screen or take the user to the expected content" (santosh07, Apple Developer Forums, thread 776156, March 2025).

There is no `NSUserActivity` waiting on a fresh install, because the install came from the App Store, not from a tap your app could see. The link has to be reconstructed on the other side. Here is the timeline:

| Moment | Where it happens | What is recorded or sent |
|---|---|---|
| 1. The tap | Safari or an in-app browser, before the redirect | The click, the request IP, the primary language, and the timezone. The match key is hashed one way. Each tap can be matched only within the match window and pending entries expire automatically. |
| 2. The App Store | Apple | Nothing. The store passes no referrer and no URL to the new app. |
| 3. The first launch | Your app, inside `configure()` | The SDK sends the preferred language, the IANA timezone name and offset, and the IDFV. |
| 4. The match | The attribution endpoint | The server reads the request IP itself, rebuilds the match key, and claims the most recent matching click. |
| 5. The callback | Your `onLink` | A `WarpLinkDeepLink` with `isDeferred`, the destination, the iOS deep link URL, `matchType`, `matchConfidence`, and `matchGuaranteed`. |

Clicks that produce the same match key are kept as a list, newest first, capped at 10, so a second person behind the same address does not overwrite the first. Each match claims one click.

## Why First Installs Need a Probabilistic Match

A normal deep link is delivered. A deferred deep link is matched. The confidence you can have in that match depends on which signal produced it, and on iOS the options are narrower than on Android.

### There is no referrer on iOS

On Android, the Play Store carries a referrer string through the install, so the server can be told exactly which link produced it. For an ordinary link, iOS has no referrer, and the match cascade starts at the probabilistic match. Referral links add a confirmation step, described in the next section.

### Referral links on iOS

SDK 1.2.0 adds referral links, which carry an invitation code through the install. With **Offer referral handoff on iOS** turned on for the app, an iPhone visitor without your app sees a short invitation page, then the App Store. On first launch, add `WarpLinkPasteButton` (SwiftUI, iOS 16 and later) or `WarpLinkUIPasteButton` (UIKit) to your first screen. The person taps the system paste control, the SDK confirms the invitation, and the match is exact (the match method is `install_token`).

```swift
WarpLinkPasteButton { result in
    if case .success(let link) = result, link != nil { showInvitationApplied() }
}
```

The SDK never reads the clipboard. The person taps the system paste button for each paste, and `nil` comes back when nothing is confirmed, so onboarding is never gated. After sign-up, `claimReferral(installerId:)` credits the install and your backend receives a signed `referral.claimed` webhook. The [iOS attribution docs](https://warplink.app/docs/sdks/ios/attribution) and the [referrals guide](https://warplink.app/docs/guides/referrals) cover the full path.

### The IDFV answers a repeat check, not a first install

The SDK sends the IDFV, the Identifier for Vendor, whenever iOS provides one. It is stable across all apps from the same vendor on a device and needs no permission. iOS can return no value, for example after a restart before the user first unlocks the device. The SDK then leaves the field out, and the check runs on the match key alone. When an install that already has an attribution record asks again, for example because an earlier check was cut off before its answer arrived, the IDFV finds that record and returns the result it already holds, with its match type and confidence unchanged.

What the IDFV does not do is create a match. A first install has no earlier record to find, so it falls through to the probabilistic match. A reinstall the SDK reports with `is_reinstall` is deliberately skipped as well, so it is recorded as the new install it is. And when your only app is deleted, iOS resets the IDFV anyway.

### The probabilistic match, and how it is scored

For a first install, the server compares the signals captured at the tap with the signals sent at launch: IP address, normalized primary language, and timezone. The SDK sends the language and timezone raw, and the server computes the hash with the request IP on both sides, because an app cannot see its own public IP.

There are three match key variants. `enriched_tz` hashes the IANA zone name, such as `America/Toronto`, and is what the iOS SDK sends whenever the zone name is available. `enriched` hashes the minute offset instead. `basic` drops the timezone and keys on IP and language alone.

| Time since click | `enriched_tz` | `enriched` | `basic` |
|------------------|---------------|------------|---------|
| < 1 hour | 0.85 | 0.80 | 0.70 |
| < 3 hours | 0.65 | 0.60 | 0.50 |
| < 6 hours | 0.50 | 0.45 | 0.35 |
| < 24 hours | 0.30 | 0.25 | 0.20 |

The zone name earns the higher ceiling because it carries far more entropy than the offset, roughly 340 zones against 38 offsets, and it does not shift at a daylight-saving boundary.

Two multipliers then adjust the score for shared and carrier addresses:

- **The bucket held more than one distinct link:** x0.6. The answer was chosen from a set rather than found outright.
- **The click's IP was carrier-grade NAT or private:** x0.6. A household IPv4 address is x0.9, and IPv6 is x1.0.

The match window caps all of this. It is set per link in the dashboard, not in the SDK: 6 hours by default, anywhere from 1 to 24 hours, and past the window there is no match at all. The window is short by design, which keeps the match key bucket tight and the matches relevant. Inside the first hour the match is strong.

### App Tracking Transparency: no prompt needed

The most common worry is that deferred deep linking requires the IDFA and therefore the ATT permission prompt. It does not. The IDFA is the advertising identifier, gated behind ATT and zeroed out unless the user grants tracking. The IDFV is vendor scoped and not covered by ATT, because it cannot follow a user across other companies' apps. The match key is computed on the server. It is a hash of the request address, language, and timezone that restores the tapped link on first open. It is not used to follow users across other companies' apps or websites. Each tap can be matched only within the app's match window, and pending entries expire automatically. The raw IP address is not stored. Neither needs a permission dialog, and neither degrades when a user taps "Ask App Not to Track."

### iCloud Private Relay

Private Relay replaces the device's public IP with an exit address from the relay. That is exactly why the SDK never computes the IP part of the match key on the device: the server computes it from the request on both the click side and the install side, so the two hashes are built the same way. A relayed exit address is a public routable address, so it is scored as ordinary IPv4 or IPv6 rather than taking the shared-address discount.

## Set Up the SDK in Swift

The SDK supports iOS 15 and later and installs through Swift Package Manager. In Xcode, add the package from `https://github.com/WarpLinkApp/warplink-ios-sdk` and pick the `WarpLink` library. Create the key in the dashboard under **Keys**, then **SDK keys**. Configure the SDK with that SDK key, since only an SDK key records installs.

### Step 1: Configure once, with one onLink callback

Call `configure()` as early as possible: in your SwiftUI `App` initializer, or in `application(_:didFinishLaunchingWithOptions:)`. One callback receives everything: a tapped Universal Link, a link that arrives while the app is running, and a deferred install match. `isDeferred` tells them apart.

```swift
import WarpLink

enum DeepLinkSetup {
    static func start() {
        WarpLink.configure(
            apiKey: "wl_live_yoursdkkeyhere000000000000000000",
            options: WarpLinkOptions(
                debugLogging: true,
                onLink: { result in
                    Task { @MainActor in Router.shared.handle(result) }
                }
            )
        )
    }
}
```

`onLink` fires for a match or an error. A no-match is not delivered, because a no-match is not a link. An offline first launch arrives as a failure, and the check is not consumed, so the next launch retries on its own. The match window lives on the link, so there is nothing to pass for it here.

### Step 2: Take the check yourself when you need the no-match answer

If your first screen depends on knowing that nothing matched, for example to hold a splash screen until the answer arrives, turn off the automatic check and call `checkDeferredDeepLink` yourself. The completion runs on the main thread, so it can drive navigation directly.

```swift
WarpLink.configure(
    apiKey: "wl_live_yoursdkkeyhere000000000000000000",
    options: WarpLinkOptions(
        autoDeferredCheck: false,
        onLink: { result in
            if case .success(let deepLink?) = result { Router.shared.routeTap(deepLink) }
        }
    )
)

WarpLink.checkDeferredDeepLink { result in
    Task { @MainActor in
        switch result {
        case .success(let deepLink?):
            Router.shared.routeDeferred(deepLink)
        case .success(nil):
            Router.shared.showOnboarding() // definitive no-match, cached for this install
        case .failure:
            Router.shared.showOnboarding() // not cached: the next launch retries
        }
    }
}
```

A second call while a check is in flight does not start another request. It waits for the running check and gets the same answer. After the check completes, later calls return the cached result with no network request.

## Route by Match Confidence

`WarpLinkDeepLink` carries the destination, the link's iOS deep link URL when it has one, and three attribution fields: `matchType`, `matchConfidence`, and `matchGuaranteed`. The router below is the one every snippet in this guide calls.

```swift
import WarpLink

@MainActor
final class Router {
    static let shared = Router()

    func handle(_ result: Result<WarpLinkDeepLink?, WarpLinkError>) {
        switch result {
        case .success(let deepLink?):
            deepLink.isDeferred ? routeDeferred(deepLink) : routeTap(deepLink)
        case .success(nil):
            break
        case .failure(let error):
            report(error)
        }
    }

    func routeDeferred(_ deepLink: WarpLinkDeepLink) {
        let target = deepLink.deepLinkUrl ?? deepLink.destination

        // Shared routing code can use this on both platforms.
        if deepLink.matchGuaranteed {
            open(target)
            return
        }

        let confidence = deepLink.matchConfidence ?? 0
        if confidence > 0.5 {
            open(target) // strong match: public content only
        } else {
            showWelcome(suggestedContent: deepLink.destination)
        }
    }

    func routeTap(_ deepLink: WarpLinkDeepLink) {
        open(deepLink.deepLinkUrl ?? deepLink.destination)
    }

    func open(_ target: String) { /* push the screen for this URL */ }
    func showWelcome(suggestedContent: String) { /* name the content, let the user opt in */ }
    func showOnboarding() { /* the default first run */ }
    func report(_ error: WarpLinkError) { /* your logging */ }
}
```

The rule of thumb: route to specific public content above `0.5`, and degrade gracefully below it. "Degrade gracefully" can mean a welcome screen that names the content ("Looking for the Blue Running Shoes? Here it is.") so the user opts in, rather than a hard redirect on a weak score.

Treat a deferred match as routing, not identity. Keep sign-in, sessions, personal data, and purchases on your own authentication.

`deepLinkUrl` is the link's own iOS deep link URL, returned verbatim, so campaign values you write onto it survive the store round trip:

```swift
func campaign(of deepLink: WarpLinkDeepLink) -> String? {
    guard let raw = deepLink.deepLinkUrl,
          let items = URLComponents(string: raw)?.queryItems else { return nil }
    return items.first(where: { $0.name == "utm_campaign" })?.value
}
```

That is the bridge from routing the user to attributing the install.

## Test a Real First Install

Deferred matching runs once per install, on the genuine first launch, so most "it does not work" reports are testing artifacts. This sequence exercises the path real users take:

1. Delete the app from the test device. Deleting clears the completion marker, so the next install checks again.
2. Tap the link on that device, in Safari or in the app you will share from, and let it land on the App Store page.
3. Install the build from TestFlight. An Xcode run is not a store install, and a rerun over an existing install keeps the marker.
4. Open the app within the hour, on the same network you tapped from. That stays inside the default window and the top confidence band.
5. With `debugLogging: true`, watch the console for the check and the matched link ID, and confirm `onLink` receives `isDeferred == true`.

A reinstall is a new install: the completion marker dies with the app, so the check runs again and is attributed again. A separate device-level marker in the Keychain survives the delete and reports the run as a reinstall. Both count as installs. To exercise the first-install path specifically, use a device that has never run the app, or erase a simulator with `xcrun simctl erase`.

A good result on iOS reads `matchType` of `probabilistic`, `matchGuaranteed` of `false`, and a score near 0.85 when you opened inside the hour on a home network.

## Troubleshooting the Match

| Symptom | Likely cause | What to check |
|---|---|---|
| `onLink` never fires on first launch | The check already completed for this install | Was this a fresh install? An Xcode rerun keeps the marker. Delete the app and reinstall. |
| `onLink` fires with a network error | The device was offline on first launch | Nothing to fix. The attempt is not consumed, and the next launch retries. |
| The check answers with no match | The tap is older than the link's match window | Time between tap and launch. The default window is 6 hours, set per link. |
| No match, or a low score, after a quick test | The tap and the launch came from different networks | Did you tap on Wi-Fi and launch on cellular, or the reverse? Private Relay can move the IP too. |
| Installs missing from the dashboard | Configure the SDK with an SDK key, not an API key | The key's type under **Keys** in the dashboard. |
| A match arrives but opens Safari | Universal Link routing, not deferred matching | The apple-app-site-association file (check it with our [AASA validator](https://warplink.app/tools/aasa-validator)) and the Associated Domains entitlement. |
| A match is rejected after the window | The device clock is badly wrong | Date and time settings on the device. |

If the link fires but opens Safari instead of your screen once the app is installed, that is a Universal Link problem with its own checklist, linked below.

## Frequently Asked Questions

**What is deferred deep linking on iOS?**
It lets a user tap a link, install your app from the App Store, and still land on the intended screen on first launch. iOS has no API for it and the App Store passes nothing to the new app, so the tap is recorded on the server with the signals a browser exposes, and the first launch is matched back to it on a match key of network, language, and timezone. The match comes back with a confidence score.

**How do I set up deferred deep linking on iOS?**
Add the WarpLink package through Swift Package Manager, create an SDK key in the dashboard, and call `WarpLink.configure` with an `onLink` callback as early as your app starts. The SDK runs the first-launch check on its own and delivers a match to `onLink` with `isDeferred` set to true. Route on `matchConfidence`, then test with a fresh TestFlight install inside the link's match window.

**Can iOS referral links be matched exactly?**
Yes, with the paste step. A referral link carries an invitation code, and the person taps the system paste control on first launch so the SDK can confirm it. The match is exact and reports `install_token` as its method. The SDK never reads the clipboard, and onboarding is never gated on the paste. See the [referrals guide](https://warplink.app/docs/guides/referrals).

**Does deferred deep linking require the IDFA or the App Tracking Transparency prompt?**
No. Matching uses a server-side match key plus the IDFV, which is vendor scoped, needs no permission, and is not gated by App Tracking Transparency. It never touches the IDFA, so it needs no tracking permission and no ATT prompt, and it keeps working when a user taps "Ask App Not to Track."

**Why is iOS deferred matching probabilistic when Android can be deterministic?**
The Play Store passes a click referrer through the install, so Android can name the exact link. The App Store passes nothing, so a first install on iOS is matched probabilistically and scored. The IDFV returns the result an install already holds when it asks again.

**How long does a deferred deep link last?**
As long as the link's match window: 6 hours by default, adjustable from 1 to 24 hours per link in the dashboard. Confidence also drops with time. On the strongest match key variant it is 0.85 inside an hour, 0.65 inside three, 0.50 inside six, and 0.30 out to the 24 hour ceiling.

**Why is my deferred deep link not firing in testing?**
Almost always because the run was not a fresh install, or the check already completed for this install. An Xcode rerun keeps the completion marker and returns the cached answer. Delete the app, tap the link on the device, install from TestFlight, and open it within the hour on the same network you tapped from.

**Does iCloud Private Relay break deferred deep linking?**
Private Relay is handled on the server. The app cannot see its own public IP, so the server computes the match key from the request address on both the click and the install side.

## Related Guides

- **The short version, both platforms:** [deferred deep linking](https://warplink.app/deferred-deep-linking) puts the iOS and Android matching side by side in one table.
- **The same problem on Android:** [Deferred Deep Linking on Android: How to Implement It in Kotlin](https://warplink.app/blog/deferred-deep-linking-android) covers the Play Install Referrer path, the one exact first-install signal.
- **Both platforms in one codebase:** [Deferred Deep Linking in React Native](https://warplink.app/blog/deferred-deep-linking-react-native) and [Deferred Deep Linking in Flutter](https://warplink.app/blog/deferred-deep-linking-flutter) put the two mechanisms behind one callback.
- **Invite and referral links:** [Referrals](https://warplink.app/docs/guides/referrals) covers the paste step, `claimReferral`, and the signed webhook.
- **What the match is for:** [Install Attribution Without an MMP](https://warplink.app/blog/install-attribution-without-an-mmp) explains the cascade as an attribution model.
- **When the app opens Safari instead:** [Universal Links Not Working? 15 Causes and the Fix for Each](https://warplink.app/blog/universal-links-not-opening) and [Universal Links Open Safari? Fix Your apple-app-site-association File](https://warplink.app/blog/apple-app-site-association-not-working).
- **Start here for the concepts:** [What Is Deep Linking? How Mobile Deep Links Work on iOS and Android](https://warplink.app/blog/deep-linking-guide).
- **Choosing an SDK:** [5 Deferred Deep Linking SDKs Compared](https://warplink.app/blog/deferred-deep-linking-sdks-compared) sets first-open matching, platform coverage, and published pricing side by side.
- **Reference:** the [iOS deferred deep links docs](https://warplink.app/docs/sdks/ios/deferred), the [deferred deep links concept page](https://warplink.app/docs/concepts/deferred-deep-links), and the [attribution concepts](https://warplink.app/docs/concepts/attribution).

## How WarpLink Helps

The mechanism above is the same no matter who runs it. What you cannot host inside your app are its two server halves: the redirect that records the click's signals before the App Store takes over, and the attribution endpoint that computes the match key from the request IP and walks the match cascade. WarpLink is those two halves, and in Swift they collapse to one `configure()` call with no ATT prompt and no IDFA.

Deferred deep linking is also install attribution. The same match that routes the user tells you which link, campaign, and channel drove the install, because `linkId`, `deepLinkUrl`, `matchType`, `matchConfidence`, and `matchGuaranteed` come back in one payload. That is the bridge from the linking pillar to the attribution pillar, and from there to the real-time analytics that show which taps become users.

[Create a free WarpLink account](https://dash.warplink.app/auth/signup) and get deferred deep linking, install attribution, and the analytics behind them in one SDK, with 10,000 clicks a month on the free tier and no time limit.
