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 type | Example | When it fires |
|---|---|---|
| Custom scheme | yourapp://products/42 | App is installed; opened from another app, a web page, or a notification. |
| Universal Link | https://link.paylisher.com/c/AbC123 | App 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 SwiftUIApplifecycle it lives in) are iOS 14+, because Apple'sonOpenURL/onContinueUserActivitymodifiers are 14+. To ship on iOS 13, skip that helper and wire deep links through the UIKit / SceneDelegate forwarding —handleDeepLink,handleUserActivityandhandleURLContextsall 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
- The OS delivers the link to your app (via a custom scheme or a Universal Link).
- You forward it to the SDK in one line (
paylisherDeepLinks()for SwiftUI, or a single call inAppDelegate). - 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).
- The SDK automatically attaches attribution (
campaign_key,deeplink_key,jid) to every event for that session, and emits its own deep-link analytics events. - The SDK calls your closure with the parsed
PaylisherDeepLinkso 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.
| # | Step | What you do |
|---|---|---|
| 1 | Configure the platform | Declare your custom scheme in Info.plist and add Associated Domains for Universal Links. |
| 2 | Set up the SDK | Add deepLinkConfig and call PaylisherSDK.shared.setup() once. |
| 3 | Receive links | Register onDeepLink { … } and add .paylisherDeepLinks() to your root view (one modifier). |
| 4 | Route to a screen | Turn the parsed link into navigation — pick the tab, push the nested screen. |
| 5 | Test | Fire 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>
2. Enable Universal Links (recommended)
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.comis 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.
| Property | Type | Default | Description |
|---|---|---|---|
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). |
autoRegisterCampaignKeys | Bool | true | When a link carries a campaign key, the SDK registers campaign_key + deeplink_key for the session automatically (see Automatic attribution). |
captureDeepLinkEvents | Bool | true | Emit the business event Deep Link Opened (plus Deep Link Completed / Deep Link Cancelled for auth-gated links) when a link is handled. |
captureDeepLinkDiagnostics | Bool | false | Also 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. |
autoHandleDeepLinks | Bool | true | Process links automatically on arrival. |
debugLogging | Bool | false | Verbose deep-link logs (use in debug builds). |
pendingDeepLinkTimeout | TimeInterval | 300 | How long an auth-gated link waits for login before expiring (seconds). |
Receiving deep links
Closure API (recommended)
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")")
}
The PaylisherDeepLink object
Your closure receives a parsed object:
| Property | Type | Description |
|---|---|---|
url | URL | The original incoming URL. |
scheme | String | URL scheme (e.g. yourapp, https). |
destination | String | The destination/path extracted from the URL. |
resolvedDestination | String | The 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. |
campaignKeyName | String? | The campaign key extracted from the link, if any. |
jid | String? | Journey ID for attribution. |
campaignId | String? | Campaign id from ?campaign_id= / ?campaign=, if present. |
source | String? | Traffic source from ?source= / ?utm_source=, if present. |
authParamRequired | Bool | true when the link itself carries ?auth=required (see Auth-gated destinations). |
campaignData | PaylisherResolvedDeepLinkPayload? | 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) orresolvedDestination(a ready-made single string).campaignDatais handy when you want to show the campaign'stitleor read itsmetaData.
What the link can carry
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.
| Parameter | Aliases | Effect |
|---|---|---|
keyName | key, k | Campaign key → resolved into campaignData. |
jid | — | Journey ID, attached to attribution. |
source | utm_source | Traffic source, attached to attribution. |
campaign_id | campaign | Campaign id. |
auth | — | auth=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)
Forwarding links to the SDK
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 SwiftUIApplifecycle 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:
- Parse the URL into a destination (plain values — which tab, which nested screen).
- Store that destination in an
ObservableObjectyour views observe. - 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,resolvedDestinationis enough. Real apps usually need a tab plus a nested stack (e.g. Products → detail → reviews), and bindingNavigationStack(path:)to a published array gives you exactly that.
Cold start (app was launched by the link)
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:
| Property | Meaning |
|---|---|
campaign_key | The campaign key of the link that opened the session. |
deeplink_key | Same value, under the conventional deeplink_key name. |
jid | Journey ID, for cross-event attribution. |
journey_source | How 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(defaulttrue).
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().
Deferred deep links
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
NSUserTrackingUsageDescriptioninInfo.plistfor 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
| Method | What 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. |
Forwarding links
| Method | What it does |
|---|---|
View.paylisherDeepLinks() | SwiftUI modifier — forwards onOpenURL + onContinueUserActivity to the SDK. iOS 14+. |
handleDeepLink(_ url: URL) -> Bool | Handles a custom-scheme URL. Returns true if handled. |
handleUserActivity(_ userActivity: NSUserActivity) -> Bool | Handles a Universal Link NSUserActivity. |
handleURLContexts(_ contexts: Set<UIOpenURLContext>) | Handles URLs from SceneDelegate. |
Pending (auth) & deferred
| Method | What 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
| Symptom | Cause / fix |
|---|---|
| Custom scheme does nothing | Scheme not declared in Info.plist → CFBundleURLTypes, or customSchemes doesn't include it. |
| Universal Link opens Safari instead of the app | The 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 fires | You didn't add paylisherDeepLinks() (SwiftUI) or the AppDelegate/SceneDelegate forwarders (UIKit). |
Events don't carry campaign_key | The 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 matches | Deferred not enabled, the app launched offline on first run, or the attribution window elapsed. |