Skip to main content

Deep Links (iOS)

Paylisher turns every link you create in the dashboard into a measurable, routable entry point into your app. The SDK receives the incoming link, resolves its campaign data from the backend, stamps attribution onto your analytics events, and hands you a parsed object so you can navigate the user to the right screen — with almost no code on your side.

The SDK handles three kinds of links:

Link typeExampleWhen it fires
Custom schemeyourapp://products/42App is installed; opened from another app, a web page, or a notification.
Universal Linkhttps://link.paylisher.com/c/AbC123App is installed; opened from Safari/Chrome, email, social, etc. — no scheme prompt.
Deferred deep link(resolved on first launch)App was not installed when the link was tapped; the destination is delivered on the first launch after install.

Minimum iOS — the SDK supports iOS 13+. Only the one-line SwiftUI helper paylisherDeepLinks() (and the SwiftUI App lifecycle it lives in) are iOS 14+, because Apple's onOpenURL / onContinueUserActivity modifiers are 14+. To ship on iOS 13, skip that helper and wire deep links through the UIKit / SceneDelegate forwardinghandleDeepLink, handleUserActivity and handleURLContexts all run on iOS 13.

This guide targets Paylisher iOS 1.8.4. For the Android counterpart, see the Android Deep Link Integration guide.


How it works

  1. The OS delivers the link to your app (via a custom scheme or a Universal Link).
  2. You forward it to the SDK in one line (paylisherDeepLinks() for SwiftUI, or a single call in AppDelegate).
  3. The SDK parses the URL, and — if it carries a campaign key — calls the Paylisher backend to resolve the campaign (title, type, target URLs, metadata, journey id).
  4. The SDK automatically attaches attribution (campaign_key, deeplink_key, jid) to every event for that session, and emits its own deep-link analytics events.
  5. The SDK calls your closure with the parsed PaylisherDeepLink so you can navigate.

You write step 2 and step 5. Everything else is automatic.


Integration steps

Follow these in order. Steps 1–3 are the core integration; step 4 makes navigation real; step 5 verifies it.

#StepWhat you do
1Configure the platformDeclare your custom scheme in Info.plist and add Associated Domains for Universal Links.
2Set up the SDKAdd deepLinkConfig and call PaylisherSDK.shared.setup() once.
3Receive linksRegister onDeepLink { … } and add .paylisherDeepLinks() to your root view (one modifier).
4Route to a screenTurn the parsed link into navigation — pick the tab, push the nested screen.
5TestFire links with xcrun simctl openurl and confirm the right screen opens.

Optional, add when you need them: auth-gated screens, deferred deep links. Attribution is automatic — nothing to write.


Prerequisites

Deep links require two pieces of OS-level configuration. These are platform requirements (not SDK calls) and cannot be done from code.

1. Register a custom URL scheme

In your target's Info.plist, declare the scheme your links use (replace yourapp with your own):

<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>com.yourcompany.yourapp</string>
<key>CFBundleURLSchemes</key>
<array>
<string>yourapp</string>
</array>
</dict>
</array>

Universal Links open your app directly from an https:// URL — no "Open in app?" prompt.

a. In Xcode → Target → Signing & Capabilities → Associated Domains, add:

applinks:link.paylisher.com

b. Paylisher serves the matching apple-app-site-association file at https://link.paylisher.com/.well-known/apple-app-site-association. Your app's appID (TEAMID.com.yourcompany.yourapp) must be listed there — share your Team ID and Bundle ID with Paylisher so it can be added.

link.paylisher.com is the production link domain for all Paylisher short/Universal links. If you use a custom branded domain, add it to both the Associated Domains entitlement and the dashboard, and Paylisher will host the association file for it.


Quick start

The recommended integration is three touch points. Attribution, campaign resolution, journey tracking, manager setup and cold-start handling are all automatic.

import Paylisher

// 1) Configure deep links on the config, then call setup() once (e.g. in your App init / AppDelegate).
let config = PaylisherConfig(apiKey: "phc_YOUR_PROJECT_KEY", host: "https://us.i.paylisher.com")

let deepLinkConfig = PaylisherDeepLinkConfig()
deepLinkConfig.customSchemes = ["yourapp"]
deepLinkConfig.universalLinkDomains = ["link.paylisher.com"]
deepLinkConfig.authRequiredDestinations = ["wallet", "account"] // optional
config.deepLinkConfig = deepLinkConfig

PaylisherSDK.shared.setup(config)

// 2) Register a closure — no protocol to implement.
PaylisherSDK.shared.onDeepLink { deepLink, requiresAuth in
guard !requiresAuth else { return } // auth-gated → wait (see Auth-gated destinations)
// Simplest case. For a tab + nested screens, see "Routing the user to the right screen".
navigate(to: deepLink.resolvedDestination)
}
// 3) Forward the OS link to the SDK. SwiftUI — one modifier on your root view:
import SwiftUI

@main
struct MyApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
var body: some Scene {
WindowGroup {
ContentView()
.paylisherDeepLinks() // wires onOpenURL + onContinueUserActivity to the SDK
}
}
}

That's the whole integration. The sections below explain each piece and the alternatives (UIKit, the delegate protocol, deferred links, auth gating).


Configuration — PaylisherDeepLinkConfig

Set this on config.deepLinkConfig before setup(). setup() then initializes the deep-link manager automatically.

PropertyTypeDefaultDescription
customSchemes[String][]Custom URL schemes you handle, e.g. ["yourapp"].
universalLinkDomains[String][]Domains handled as Universal Links, e.g. ["link.paylisher.com"].
authRequiredDestinations[String][]Destinations that require an authenticated user before navigation (see Auth-gated destinations).
autoRegisterCampaignKeysBooltrueWhen a link carries a campaign key, the SDK registers campaign_key + deeplink_key for the session automatically (see Automatic attribution).
captureDeepLinkEventsBooltrueEmit the business event Deep Link Opened (plus Deep Link Completed / Deep Link Cancelled for auth-gated links) when a link is handled.
captureDeepLinkDiagnosticsBoolfalseAlso emit verbose diagnostic / funnel events (deeplink_received, deeplink_resolved, deeplink_resolve_failed, deeplink_navigation) for debugging the pipeline (e.g. inspecting why a campaign key didn't resolve). Off by default to keep your event stream clean.
autoHandleDeepLinksBooltrueProcess links automatically on arrival.
debugLoggingBoolfalseVerbose deep-link logs (use in debug builds).
pendingDeepLinkTimeoutTimeInterval300How long an auth-gated link waits for login before expiring (seconds).

Register one or more closures on PaylisherSDK.shared. No protocol, no delegate object.

// Called for every received deep link.
PaylisherSDK.shared.onDeepLink { deepLink, requiresAuth in
// requiresAuth == true → the destination is auth-gated; don't navigate yet.
if !requiresAuth {
navigate(to: deepLink.resolvedDestination)
}
}

// Optional: called when an auth-gated destination needs a logged-in user.
PaylisherSDK.shared.onDeepLinkRequiresAuth { deepLink, complete in
if isUserLoggedIn {
complete(true) // proceed to the destination
} else {
showLogin { success in complete(success) }
}
}

// Optional: called when a link can't be parsed/handled.
PaylisherSDK.shared.onDeepLinkFailed { url, error in
print("Deep link failed: \(url)\(error?.localizedDescription ?? "unknown")")
}

Your closure receives a parsed object:

PropertyTypeDescription
urlURLThe original incoming URL.
schemeStringURL scheme (e.g. yourapp, https).
destinationStringThe destination/path extracted from the URL.
resolvedDestinationStringThe effective destination — derived from resolved campaign data when available, else destination.
pathSegments[String]Normalized route segments: [host] + path for custom schemes, path for Universal Links. So yourapp://products/42/reviews and https://link.paylisher.com/products/42/reviews both give ["products","42","reviews"]. Use this to route nested screens — no URL re-parsing, identical on iOS & Android.
parameters[String: String]All query parameters.
campaignKeyNameString?The campaign key extracted from the link, if any.
jidString?Journey ID for attribution.
campaignIdString?Campaign id from ?campaign_id= / ?campaign=, if present.
sourceString?Traffic source from ?source= / ?utm_source=, if present.
authParamRequiredBooltrue when the link itself carries ?auth=required (see Auth-gated destinations).
campaignDataPaylisherResolvedDeepLinkPayload?Full campaign object resolved from the backend (title, type, iosUrl, metaData, …), or nil.

For navigation, prefer pathSegments (already normalized, cross-platform — you don't re-parse the URL) or resolvedDestination (a ready-made single string). campaignData is handy when you want to show the campaign's title or read its metaData.

When you build a link in the dashboard — or a campaign points one at your app — these query parameters are parsed automatically into the object above. You don't read the query string yourself; you just react to the parsed fields.

ParameterAliasesEffect
keyNamekey, kCampaign key → resolved into campaignData.
jidJourney ID, attached to attribution.
sourceutm_sourceTraffic source, attached to attribution.
campaign_idcampaignCampaign id.
authauth=required gates this one link behind login (see Auth-gated destinations).

Example a campaign would send: yourapp://products/42?keyName=SUMMER25&source=push

Delegate alternative

If you prefer a delegate over closures, implement PaylisherDeepLinkHandler and register it with setDeepLinkHandler(_:):

extension AppDelegate: PaylisherDeepLinkHandler {
func paylisherDidReceiveDeepLink(_ deepLink: PaylisherDeepLink, requiresAuth: Bool) { /* … */ }
func paylisherDeepLinkRequiresAuth(_ deepLink: PaylisherDeepLink, completion: @escaping (Bool) -> Void) { /* … */ }
func paylisherDeepLinkDidFail(_ url: URL, error: Error?) { /* … */ }
}

PaylisherSDK.shared.setDeepLinkHandler(self)

SwiftUI

Add the modifier once to your root view:

ContentView().paylisherDeepLinks()

It is equivalent to wiring both onOpenURL (custom schemes) and onContinueUserActivity(NSUserActivityTypeBrowsingWeb) (Universal Links) to the SDK.

Targeting iOS 13? paylisherDeepLinks() and the SwiftUI App lifecycle are iOS 14+. Skip this modifier and use the UIKit / SceneDelegate forwarding below — the SDK's forwarding methods support iOS 13.

UIKit / AppDelegate

If you are not on the SwiftUI lifecycle (or you target iOS 13), forward the links yourself:

// Custom scheme (iOS 12 and below, or no SceneDelegate)
func application(_ app: UIApplication, open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
return PaylisherSDK.shared.handleDeepLink(url)
}

// Universal Links
func application(_ application: UIApplication, continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
return PaylisherSDK.shared.handleUserActivity(userActivity)
}
// SceneDelegate (iOS 13+) — the recommended path when you target iOS 13.
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) { // custom scheme (warm)
PaylisherSDK.shared.handleURLContexts(URLContexts)
}
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { // Universal Link (warm)
_ = PaylisherSDK.shared.handleUserActivity(userActivity)
}
// Cold start: a link that launched the app arrives in connectionOptions.
func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
PaylisherSDK.shared.handleURLContexts(connectionOptions.urlContexts)
connectionOptions.userActivities.forEach { _ = PaylisherSDK.shared.handleUserActivity($0) }
}

Routing the user to the right screen

The SDK parses the link and calls your closure — turning it into a screen is your app's job. The closure gives you a PaylisherDeepLink; you decide which tab/screen it maps to and navigate there.

The reliable pattern is three small steps:

  1. Parse the URL into a destination (plain values — which tab, which nested screen).
  2. Store that destination in an ObservableObject your views observe.
  3. Render it: SwiftUI reacts to the published state.

Doing it this way means the same code handles cold start, foreground, and deferred links — the closure only updates state, and the UI applies whatever destination is current as soon as it's on screen.

1 & 2. A router that parses the URL and publishes the destination

Keep navigation state in one ObservableObject. The SDK closure writes to it; your views observe it. Parsing is a pure function you can reuse.

import SwiftUI
import Paylisher

enum AppTab { case home, products, wallet, profile }
enum ProductRoute: Hashable { case detail(String), reviews(String) }

final class DeepLinkRouter: ObservableObject {
static let shared = DeepLinkRouter()

@Published var selectedTab: AppTab = .home
@Published var productsPath: [ProductRoute] = [] // nested stack inside Products

/// yourapp://products/42/reviews -> Products tab, detail "42" -> reviews
func navigate(_ deepLink: PaylisherDeepLink) {
// pathSegments is already normalized by the SDK — no URL re-parsing, and identical on iOS & Android.
// yourapp://products/42/reviews and https://link.paylisher.com/products/42/reviews
// both arrive as ["products","42","reviews"].
let segs = deepLink.pathSegments
DispatchQueue.main.async {
switch segs.first?.lowercased() {
case nil, "home":
self.selectedTab = .home
case "products":
self.selectedTab = .products
if let id = segs.dropFirst().first ?? deepLink.parameters["id"] {
self.productsPath = segs.count >= 3 && segs[2].lowercased() == "reviews"
? [.detail(id), .reviews(id)]
: [.detail(id)]
} else {
self.productsPath = [] // products list
}
case "wallet": self.selectedTab = .wallet
case "profile": self.selectedTab = .profile
default: self.selectedTab = .home // unknown link → safe default
}
}
}
}

// In setup(), right after PaylisherSDK.shared.setup(config):
PaylisherSDK.shared.onDeepLink { deepLink, requiresAuth in
if !requiresAuth { DeepLinkRouter.shared.navigate(deepLink) } // auth-gated: see below
}

3. Bind the router to your views

TabView binds to selectedTab; the nested NavigationStack binds to productsPath:

struct RootView: View {
@StateObject private var router = DeepLinkRouter.shared

var body: some View {
TabView(selection: $router.selectedTab) {
HomeView().tag(AppTab.home)

NavigationStack(path: $router.productsPath) {
ProductsListView()
.navigationDestination(for: ProductRoute.self) { route in
switch route {
case .detail(let id): ProductDetailView(id: id)
case .reviews(let id): ProductReviewsView(id: id)
}
}
}
.tag(AppTab.products)

WalletView().tag(AppTab.wallet)
ProfileView().tag(AppTab.profile)
}
}
}

Why parse the URL yourself instead of just using resolvedDestination? For a single flat screen, resolvedDestination is enough. Real apps usually need a tab plus a nested stack (e.g. Products → detail → reviews), and binding NavigationStack(path:) to a published array gives you exactly that.

Because the closure is wired in setup() and only writes published state, a cold-start link sets the router before your first view appears. SwiftUI applies the current selectedTab / productsPath as the UI builds — so the user lands directly on the target screen.

If your app starts on a login screen, don't navigate while logged out: the router state is already set, so just show the main UI after a successful login and SwiftUI applies it.

struct ContentView: View {
@State private var isLoggedIn = false
var body: some View {
if isLoggedIn { RootView() } // router state applied as this appears
else { LoginView { isLoggedIn = true } }
}
}

This is the same flow the auth-gate uses (next section) — the destination is stored, then applied after login.


Automatic attribution

This is the part you don't have to write. When a deep link carrying a campaign key arrives, the SDK attaches attribution to every analytics event for that session, so you can build the user's journey/path from the deep link onward:

PropertyMeaning
campaign_keyThe campaign key of the link that opened the session.
deeplink_keySame value, under the conventional deeplink_key name.
jidJourney ID, for cross-event attribution.
journey_sourceHow the journey started (e.g. deeplink).

Session-scoped by design. These properties are attached only to events in the session the link opened. If the app is killed and later opened organically (no deep link), they are not carried over — so organic sessions stay clean. Controlled by autoRegisterCampaignKeys (default true).

No register(...) calls are needed on your side; disable it with deepLinkConfig.autoRegisterCampaignKeys = false if you want to manage it manually.


Auth-gated destinations

Some screens should only open for a logged-in user. A link is treated as auth-gated when either of these is true:

requiresAuth = (destination is in authRequiredDestinations)  OR  (link carries ?auth=required)

Option 1 — by destination (config). List the always-protected destinations once:

deepLinkConfig.authRequiredDestinations = ["wallet", "account"]

Every link to wallet/account is then gated, regardless of how it was built.

Option 2 — per link (?auth=required). A single link can opt in without any config change — useful when a campaign wants one specific link (e.g. an "apply" or "checkout" link) to require login:

yourapp://campaigns/summer/apply?auth=required

In both cases the SDK invokes your auth closure instead of navigating immediately:

PaylisherSDK.shared.onDeepLinkRequiresAuth { deepLink, complete in
if isUserLoggedIn {
complete(true) // already authenticated → proceed
} else {
presentLogin { success in
complete(success) // after login, the SDK resumes to the destination
}
}
}

A typical pattern: if the user is already logged in (and the app wasn't killed), call complete(true) for a direct hand-off; if not (e.g. a cold start from a killed state), present login and call complete(true) once they succeed. After complete(true), the SDK finishes the pending link and calls your onDeepLink closure with requiresAuth == false.

You can also drive this manually with completePendingDeepLink() / cancelPendingDeepLink().


A deferred deep link is one tapped before the app was installed: the user clicks a Paylisher link, lands on the App Store, installs, and on first launch the SDK recovers the original destination via install attribution.

Enable it on the config and check once after setup():

config.deferredDeepLinkConfig = PaylisherDeferredDeepLinkConfig()
.withEnabled(true)
.withAttributionWindow(24 * 60 * 60 * 1000) // 24h (industry standard)
.withIDFA(true) // best attribution accuracy (requires ATT consent)

PaylisherSDK.shared.setup(config)

PaylisherSDK.shared.checkDeferredDeepLink(
onSuccess: { deepLink in navigate(to: deepLink.resolvedDestination) },
onNoMatch: { /* organic install — nothing to do */ },
onError: { error in print("Deferred check failed: \(error)") }
)

The deferred-attribution backend is hosted and managed by Paylisher — you don't configure an endpoint. Attribution matching is keyed on a privacy-preserving device fingerprint; enabling IDFA (with App Tracking Transparency consent) improves match accuracy.

On iOS 13 deferred works too. ATT (the tracking prompt) is iOS 14.5+, so the SDK simply skips it on iOS 13 — it reads the advertising id directly when available and otherwise falls back to a non-IDFA fingerprint (IDFV, device model, locale, …). Nothing crashes and no prompt appears; you only need NSUserTrackingUsageDescription in Info.plist for the prompt that 14.5+ devices show.


Testing

With the app running on a simulator, trigger links from the terminal:

# Custom scheme
xcrun simctl openurl booted "yourapp://products/42"

# Campaign key (resolves campaign data from the backend)
xcrun simctl openurl booted "yourapp://products?keyName=YOUR_CAMPAIGN_KEY"

# Universal Link (opens the app once the association file is live for your appID)
xcrun simctl openurl booted "https://link.paylisher.com/c/YOUR_CAMPAIGN_KEY"

Set deepLinkConfig.debugLogging = true to see [PaylisherDeepLink] logs in the Xcode console. To verify automatic attribution, open a campaign link, then fire any event and confirm it carries campaign_key / deeplink_key / jid.


API reference

Unless noted otherwise, methods are on the shared singleton PaylisherSDK.shared.

Setup & handlers

MethodWhat it does
onDeepLink(_ handler: (PaylisherDeepLink, Bool) -> Void)Registers a closure for received deep links. The Bool is requiresAuth. No protocol needed.
onDeepLinkRequiresAuth(_ handler: (PaylisherDeepLink, @escaping (Bool) -> Void) -> Void)Registers a closure for auth-gated destinations. Call the completion with true/false.
onDeepLinkFailed(_ handler: (URL, Error?) -> Void)Registers a closure for parse/handle failures.
setDeepLinkHandler(_ handler: PaylisherDeepLinkHandler)Delegate-based alternative to the closures above.
MethodWhat it does
View.paylisherDeepLinks()SwiftUI modifier — forwards onOpenURL + onContinueUserActivity to the SDK. iOS 14+.
handleDeepLink(_ url: URL) -> BoolHandles a custom-scheme URL. Returns true if handled.
handleUserActivity(_ userActivity: NSUserActivity) -> BoolHandles a Universal Link NSUserActivity.
handleURLContexts(_ contexts: Set<UIOpenURLContext>)Handles URLs from SceneDelegate.

Pending (auth) & deferred

MethodWhat it does
completePendingDeepLink()Completes a pending auth-gated link after successful login.
cancelPendingDeepLink()Cancels a pending link (emits a Deep Link Cancelled event).
checkDeferredDeepLink(onSuccess:onNoMatch:onError:)Checks for a deferred (install-attribution) deep link on first launch.

Troubleshooting

SymptomCause / fix
Custom scheme does nothingScheme not declared in Info.plist → CFBundleURLTypes, or customSchemes doesn't include it.
Universal Link opens Safari instead of the appThe apple-app-site-association for link.paylisher.com doesn't yet list your appID. Send your Team ID + Bundle ID to Paylisher. iOS caches the association — delete and reinstall the app to refresh.
onDeepLink never firesYou didn't add paylisherDeepLinks() (SwiftUI) or the AppDelegate/SceneDelegate forwarders (UIKit).
Events don't carry campaign_keyThe link had no campaign key, or autoRegisterCampaignKeys is false. Attribution is also session-scoped — it won't appear in organic (no-deep-link) sessions.
Deferred link never matchesDeferred not enabled, the app launched offline on first run, or the attribution window elapsed.