Platform: iOS

iOS deep linking that survives the trip to the App Store.

Universal links open your app when it is installed. They do nothing when it is not. Ferry covers both halves from one URL: open the app, or open the App Store and hand you the same payload on first launch.

SHORT ANSWER

iOS deep linking works through universal links: an https URL your app claims with the Associated Domains entitlement, verified against an apple-app-site-association file on that hostname. iOS opens the app directly when it is installed, and falls through to the browser when it is not, which is where the destination is normally lost. Ferry serves the association file, routes the fallback to the App Store, and returns the original payload on the first launch after install through one Swift callback.

01 TAP A universal link on your Ferry hostname
02 APP OR STORE Installed opens the app. Not installed goes to the App Store.
03 FIRST LAUNCH Ferry.onLink delivers the payload with isDeferred set to true
HOW IT WORKS ON IOS

Universal links setup, end to end.

Four things have to line up before a link opens your app, and one more before it survives an install. None of them are optional, and iOS will not tell you which one is wrong.

  1. Claim the domain in the app

    Enable the Associated Domains capability on the app ID and the target, then add one applinks: entry per Ferry hostname. The entitlement is what makes iOS ask the domain whether your app is allowed to open its URLs.

  2. Serve a valid AASA file

    The hostname must return /.well-known/apple-app-site-association over HTTPS with no redirect, ideally as application/json, listing your TEAMID.bundle.id and the paths the app claims. Ferry compiles and publishes that file from the project settings for every hostname assigned to the project.

  3. Handle both entry points

    SwiftUI receives universal links through onContinueUserActivity(NSUserActivityTypeBrowsingWeb) and other URLs through onOpenURL. UIKit receives the same two through the app delegate. Both paths need to reach the same router, which is what Ferry.handle plus a single Ferry.onLink gives you.

  4. Cover the install gap

    A universal link cannot open an app that is not installed, so the tap goes to the App Store and the destination is gone. Ferry records that open, and on the first launch after install it matches the device back to it server-side and returns the payload with isDeferred set to true and a confidence value you can act on.

FERRY SWIFT SDK
AcmeApp.swift swift
import SwiftUI
import Ferry

@main
struct AcmeApp: App {
    init() {
        Ferry.configure(publicKey: "pk_live_xxx")

        // One callback for both cases: a universal link opened while the app is
        // installed, and the deferred match on the first launch after install.
        Ferry.onLink { link in
            guard link.data["screen"] == "product",
                  let productID = link.data["product_id"] else {
                Router.shared.showHome()
                return
            }

            Router.shared.showProduct(id: productID, wasDeferred: link.isDeferred)
        }
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
                .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { Ferry.handle($0) }
                .onOpenURL { Ferry.handle($0) }
        }
    }
}

That is the whole app-side integration. No AASA hosting, no App Store fallback logic, and no second code path for the deferred case.

WHERE IT BREAKS

The failures that cost a day each.

Universal links fail silently. The link opens Safari, everything looks fine, and nothing tells you which of the five preconditions was not met.

What goes wrong What to do about it
The AASA file redirects or is served as text/plain. Apple's CDN fetches /.well-known/apple-app-site-association on your behalf and rejects anything that redirects; a wrong content type adds one more variable you do not want. Serve it directly from the link hostname with a 200 as application/json. Ferry serves it from the edge, with no redirect on that path, for every hostname assigned to a project.
The Team ID in the file does not match the build. The appID entry is TEAMID.bundle.id, and the Team ID for a transferred or enterprise app is not always the one you expect. Read it from the entitlements of the built .app rather than from the Xcode UI.
Links open Safari instead of the app. iOS caches its view of a domain and stops honouring it once a user picks the browser from the smart banner. Delete the app, reinstall, and test the link from Notes or Messages.
A link pasted in the address bar never opens the app. That is intended behaviour, not a bug. A URL typed or pasted into Safari is a browser navigation rather than a link tap, and iOS keeps it in the browser. Always test from another app.
Deferred links are only tested on the simulator. Fingerprint matching depends on real device signals, and the store install never happens on a simulator. Test the install gap on a physical device with the app fully deleted first.
Nothing arrives on the first launch after install. Register Ferry.onLink during launch. Registering the callback is what starts the deferred flow, and Ferry calls /v1/match once per install rather than on every cold start.
FAQ

Questions developers ask first.

Do universal links work if the app is not installed?

No. A universal link can only open an app that is installed and has claimed the domain. Every other visitor follows the same URL in a browser, which is why Ferry routes them to the App Store and then matches the install back to the original link open.

What is the apple-app-site-association file?

It is a JSON file served at https://yourhostname/.well-known/apple-app-site-association that tells iOS which apps may open which paths on that domain. Apple's CDN fetches it from your server and devices read Apple's copy around install time. It has to be served over HTTPS with no redirects, and Apple asks for content type application/json. Ferry serves it for every hostname assigned to a project.

How accurate is deferred deep linking on iOS?

It depends on the signal. An App Clip handoff or a click ID Ferry can read is deterministic. Without one, matching falls back to a coarse device fingerprint and returns a probabilistic score. When Ferry cannot separate two candidate links it returns no match instead of guessing.

Does the Ferry iOS SDK work with both SwiftUI and UIKit?

Yes. Ferry.handle accepts an NSUserActivity and a URL, so SwiftUI onContinueUserActivity and onOpenURL, and the UIKit application(_:continue:restorationHandler:) and application(_:open:options:) methods, all forward into the same Ferry.onLink callback.

Do I still need a custom URL scheme?

Not for links you send to users. Universal links are the only mechanism that opens your app from Mail, Messages and the web without a prompt. A custom scheme is still useful for your own internal navigation, and Ferry.handle accepts those URLs too.

Which iOS versions are supported?

iOS 15 and later, with Swift 5.9 or later. The SDK has no third-party runtime dependencies and accepts only a project pk_ public key, which is safe to ship in the app.

SHIP THE LINK, NOT THE BOILERPLATE

Get a working universal link today.

Claim a hostname, drop the SDK into your app, and test both paths: an installed open, and a fresh install from the App Store.