Deep linking
Route notification taps, external URLs, and OneLink campaigns into your app's navigation — cold-start, warm-start, and dedup.
The SDK exposes two listener APIs for deep links:
addNotificationOpenedListener— fires when a notification is tapped, with the full payload (always fires on tap, deep link or not)addDeepLinkOpenedListener— fires only when a notification (or other source) carries a deep-link URL to route
There's also a one-shot handleDeepLink(url) for external URLs your app
receives from outside the SDK (e.g. iOS Universal Link / Android App Link
intents, AppsFlyer OneLink callbacks).
This guide covers all three.
1. Notification taps with a deep link
The campaign payload includes a deep-link route. On tap, the SDK fires both listeners with a structured payload. The minimum you need is:
payload.route— the deep-link target (path like/products/123or a full URL likemyapp://offer/abc). May benullif the campaign didn't set one.payload.keyValuePairs— any custom key-value pairs the campaign attached (parsed from thekeyValuePairsJSON-encoded string on the wire).
The notification-opened payload additionally carries title, body, and an
extras map for non-canonical fields (e.g. oneLinkParams —
see section 3).
import cx.swan.sdk.Swan
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// REQUIRED on cold-start to capture the launching intent
Swan.handleNotificationIntent(intent)
// Listener fires for every notification tap that carries a route
Swan.addDeepLinkOpenedListener { payload ->
payload.route?.let { navController.navigate(it) }
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// REQUIRED on warm-start (app was backgrounded when user tapped)
Swan.handleNotificationIntent(intent)
}
}If you also want the full notification payload (title, body, extras), use
addNotificationOpenedListener:
Swan.addNotificationOpenedListener { payload ->
Log.d("push", "tapped: route=${payload.route} title=${payload.title}")
val customField = payload.keyValuePairs["myField"]
val oneLink = payload.extras["oneLinkParams"]
}NotificationOpenedPayload:
| Field | Type | Description |
|---|---|---|
route | String? | Deep-link target — path (/products/123) or full URL (https://example.com/p/1, myapp://offer/abc). null when the campaign sent no route. Empty strings normalize to null. |
title | String? | Notification title (FCM data.title). |
body | String? | Notification body (FCM data.body). |
keyValuePairs | Map<String, Any?> | Custom key/value pairs from the campaign, parsed from the JSON-encoded wire field. Always non-null — empty map when absent, empty, or unparseable. Values are primitives or nested maps / lists. |
extras | Map<String, String> | Remaining FCM data fields preserved verbatim — oneLinkParams, oneLinkConfig, and any campaign-specific fields land here. |
DeepLinkOpenedPayload:
| Field | Type | Description |
|---|---|---|
route | String? | Deep-link target (path or full URL). |
source | DeepLinkOpenedPayload.Source | Where the deep link originated. Enum values: PUSH, EMAIL, SMS, DEEP_LINK. |
keyValuePairs | Map<String, Any?> | Custom key/value pairs. Same shape as on NotificationOpenedPayload. |
extras | Map<String, String> | Remaining wire fields preserved verbatim. |
Swan.shared.addDeepLinkOpenedListener { payload in
if let route = payload.route {
router.navigate(to: route)
}
}
Swan.shared.addNotificationOpenedListener { payload in
print("tapped:", payload.route ?? "(no route)")
}For cold-start and warm-start, forward UNUserNotificationCenter callbacks into Swan.shared.handleNotificationUserInfo(_:) from your UNUserNotificationCenterDelegate — see Getting started → iOS.
NotificationOpenedPayload:
| Field | Type | Description |
|---|---|---|
route | String? | Deep-link target — path or full URL. nil when the campaign sent no route. Empty strings normalize to nil. |
title | String? | Notification title (push data.title). |
body | String? | Notification body (push data.body). |
keyValuePairs | [String: JSONValue] | Custom key/value pairs from the campaign, parsed from the JSON-encoded wire field. Always non-nil — empty dictionary when absent or unparseable. JSONValue preserves primitives and nested objects / arrays. |
extras | [String: String] | Remaining data fields preserved verbatim — oneLinkParams, oneLinkConfig, and any campaign-specific fields land here. APNs userInfo is flattened to a string map for cross-platform parity. |
DeepLinkOpenedPayload:
| Field | Type | Description |
|---|---|---|
route | String? | Deep-link target (path or full URL). |
source | DeepLinkOpenedPayload.Source | Where the deep link originated. Enum cases: .push, .email, .sms, .deepLink. |
keyValuePairs | [String: JSONValue] | Custom key/value pairs. Same shape as on NotificationOpenedPayload. |
extras | [String: String] | Remaining wire fields preserved verbatim. |
import SwanSDK from '@loyalytics/swan-react-native-sdk';
import type {
DeepLinkOpenedPayload,
NotificationOpenedPayload,
} from '@loyalytics/swan-react-native-sdk';
import { useEffect } from 'react';
useEffect(() => {
const sdk = SwanSDK.getCurrentInstance();
const onDeepLink = (payload: DeepLinkOpenedPayload) => {
if (payload.route) navigation.navigate(payload.route, payload.keyValuePairs);
};
const onNotificationOpened = (payload: NotificationOpenedPayload) => {
console.log('tapped notification:', payload.route, payload.title);
};
const deepLinkSub = sdk?.addListener(SwanSDK.EVENTS.DEEP_LINK_OPENED, onDeepLink);
const openedSub = sdk?.addListener(SwanSDK.EVENTS.NOTIFICATION_OPENED, onNotificationOpened);
return () => {
deepLinkSub?.remove();
openedSub?.remove();
};
}, []);addListener returns a subscription object with a .remove() method — call it on unmount to detach the handler.
NotificationOpenedPayload:
| Field | Type | Description |
|---|---|---|
route | string | undefined | Deep-link target — path or full URL. undefined when the campaign sent no route. |
title | string | undefined | Notification title. |
body | string | undefined | Notification body. |
keyValuePairs | Record<string, any> | Custom key/value pairs parsed from the JSON-encoded wire field. The SDK parses it for you. |
[key: string] | any | Additional notification data fields pass through on the payload (e.g. oneLinkParams, notificationType, channelId, defaultRoute, any campaign-specific keys). |
DeepLinkOpenedPayload:
| Field | Type | Description |
|---|---|---|
route | string | undefined | Deep-link target (path or full URL). |
source | 'push' | 'email' | 'sms' | 'deepLink' | Where the deep link originated. |
keyValuePairs | Record<string, any> | Custom key/value pairs. Same shape as on NotificationOpenedPayload. |
[key: string] | any | Additional fields pass through. |
DEEP_LINK_OPENED fires for every deep link source — push taps,
email, SMS, and external URL openers — with a source field
('push' | 'email' | 'sms' | 'deepLink'). Use DEEP_LINK_OPENED if you
want one handler for all sources, or NOTIFICATION_OPENED for push-only
navigation. Both fire on a push tap.
Swan.addDeepLinkOpenedListener((payload) => {
if (payload.route) router.push(payload.route);
});Web routes deep links through the SDK's service worker on notification click — your handler runs in the main thread on the next message.
Payload structure — PushNotificationReceivedPayload (delivered to both addNotificationOpenedListener and addDeepLinkOpenedListener):
| Field | Type | Description |
|---|---|---|
messageId | string | null | Push transport-layer id. null if the campaign omitted it. |
title | string | null | Notification title. null if omitted. |
body | string | null | Notification body. null if omitted. |
route | string | null | Deep-link route from the campaign. null if the campaign sent no route. addDeepLinkOpenedListener only fires when route is non-null. |
keyValuePairs | Readonly<Record<string, string>> | null | Custom key/value pairs set on the campaign. null if none. The SDK parses the JSON-encoded form on the wire into an object. |
Cold-start gotcha (Android)
A common mistake on Android: missing the handleNotificationIntent(intent)
call in onCreate. Without it, the SDK never sees the launching notification
and the deep-link listener never fires on cold-start.
You need it in both:
onCreate(savedInstanceState)— for cold-start (app was killed)onNewIntent(intent)— for warm-start (app was backgrounded)
The SDK deduplicates by messageId for 30 seconds, so calling both is
safe — only one delivery fires per message.
Init order doesn't matter. handleNotificationIntent is safe to call
before Swan.init(...) has run. The SDK buffers the payload internally and
replays it as soon as a listener subscribes (typically a few lines later in
the same onCreate, right after init). So this is fine:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Swan.handleNotificationIntent(intent) // payload buffered if init not done
Swan.init(applicationContext, appId = "your-app-id")
Swan.addDeepLinkOpenedListener { payload -> /* fires here, with buffered payload */ }
}2. External URLs (Universal Links / App Links / OneLink)
If your app receives a deep-link URL from outside the SDK — iOS Universal Link, Android App Link / browser intent, AppsFlyer OneLink callback — push it into the SDK so the campaigns engine can track attribution.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Capture deep-link Intent if the activity was opened from an external URL
intent.data?.toString()?.let { url ->
val handled = Swan.handleDeepLink(url)
if (handled) {
navController.navigate(url)
}
}
}handleDeepLink(url) returns true if the SDK recognized and tracked the
link (typically Swan-issued links containing tracking parameters), false
otherwise. You can route the URL into your navigation either way.
// SwiftUI
.onOpenURL { url in
let handled = Swan.shared.handleDeepLink(url.absoluteString)
if handled { router.navigate(to: url) }
}
// UIKit
func application(_ app: UIApplication, open url: URL, options: ...) -> Bool {
Swan.shared.handleDeepLink(url.absoluteString)
router.navigate(to: url)
return true
}The SDK automatically tracks campaign link clicks from email / SMS /
WhatsApp — any URL opening your app with swan_ prefixed query parameters
(e.g. swan_comm_id) is silently acknowledged for attribution. No code
changes required as long as your app handles deep links at the platform
level (URL schemes, Universal Links, App Links).
For your own host-app navigation, listen for the unified deep-link event:
import SwanSDK from '@loyalytics/swan-react-native-sdk';
useEffect(() => {
const sdk = SwanSDK.getCurrentInstance();
const handler = (payload) => {
// payload.source = 'push' | 'email' | 'sms' | 'deepLink'
if (payload.route) navigation.navigate(payload.route, payload.keyValuePairs);
};
const sub = sdk?.addListener(SwanSDK.EVENTS.DEEP_LINK_OPENED, handler);
return () => sub?.remove();
}, []);// On page load — capture any deep-link query params
Swan.handleDeepLink(window.location.href);3. OneLink / AppsFlyer pass-through
The Swan backend can include oneLinkParams / oneLinkConfig JSON-encoded
strings inside a push payload. The SDK does NOT parse or act on these
fields — AppsFlyer OneLink integration is the host app's responsibility.
What the SDK does do: preserve the fields end-to-end on the payload's
extras map so your addNotificationOpenedListener callback has them
intact alongside the canonical route / title / body / keyValuePairs
fields.
Swan.addNotificationOpenedListener { payload ->
val oneLink = payload.extras["oneLinkParams"]
if (oneLink != null) {
// Hand to AppsFlyer SDK
AppsFlyerLib.getInstance().performOnDeepLinking(this, JSONObject(oneLink))
}
}Swan.shared.addNotificationOpenedListener { payload in
if let oneLink = payload.extras["oneLinkParams"] {
AppsFlyerLib.shared().performOnAppAttribution(with: URL(string: oneLink)!)
}
}import SwanSDK from '@loyalytics/swan-react-native-sdk';
const sdk = SwanSDK.getCurrentInstance();
sdk?.addListener(SwanSDK.EVENTS.NOTIFICATION_OPENED, (payload) => {
const oneLink = payload?.oneLinkParams;
if (oneLink) {
// Hand off to react-native-appsflyer
appsFlyer.performOnDeepLinking(JSON.parse(oneLink));
}
});OneLink fields (oneLinkParams, oneLinkConfig) ride alongside the canonical route / title / body / keyValuePairs on the notification payload — the SDK does not parse them.
Swan.addNotificationOpenedListener((payload) => {
if (payload.extras?.oneLinkParams) {
// Web Push doesn't have a OneLink equivalent yet
}
});Don't add AppsFlyer as a transitive dependency of the SDK. The SDK must not import or depend on AppsFlyer — that integration belongs in the host app.
4. Dedup window
The SDK deduplicates notification taps by messageId for 30 seconds.
This makes "call from both onCreate and onNewIntent" safe on Android,
and prevents accidental double-fires from cold-start + warm-start race
conditions on every platform.
Pushes that arrive without a messageId (synthetic / test pushes) are
not dedup'd — every delivery fires.
If your handler accidentally fires twice with the same messageId outside
the 30s window, that's a bug — contact support with the messageId so we
can trace it.
5. Listener cleanup
addNotificationOpenedListener and addDeepLinkOpenedListener return an
unsubscribe handle. Call it when the subscriber goes out of scope —
typically when an activity / view / component is destroyed.
import cx.swan.sdk.internal.routing.DeepLinkOpenedPayload
class MainActivity : AppCompatActivity() {
private val deepLinkHandler: (DeepLinkOpenedPayload) -> Unit = { payload ->
payload.route?.let { navController.navigate(it) }
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Swan.addDeepLinkOpenedListener(deepLinkHandler)
}
override fun onDestroy() {
Swan.removeDeepLinkOpenedListener(deepLinkHandler)
super.onDestroy()
}
}let unsubscribe = Swan.shared.addDeepLinkOpenedListener { payload in
if let route = payload.route { router.navigate(to: route) }
}
// later — call the closure returned by add* to unsubscribe:
unsubscribe()import SwanSDK from '@loyalytics/swan-react-native-sdk';
useEffect(() => {
const sdk = SwanSDK.getCurrentInstance();
const handler = (payload) => { /* ... */ };
const sub = sdk?.addListener(SwanSDK.EVENTS.DEEP_LINK_OPENED, handler);
return () => sub?.remove();
}, []);addListener(event, callback) returns a { remove(): void } subscription —
hold onto it and call .remove() when the component unmounts.
const handler = (payload) => {
if (payload.route) router.push(payload.route);
};
Swan.addDeepLinkOpenedListener(handler);
// later:
Swan.removeDeepLinkOpenedListener(handler);Reference
- Push notifications — full push lifecycle, where deep links originate
- Concepts → Push architecture — wire model
- Android:
handleDeepLink,handleNotificationIntent,addDeepLinkOpenedListener,addNotificationOpenedListener