Swan SDK
Guides

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.


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/123 or a full URL like myapp://offer/abc). May be null if the campaign didn't set one.
  • payload.keyValuePairs — any custom key-value pairs the campaign attached (parsed from the keyValuePairs JSON-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:

FieldTypeDescription
routeString?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.
titleString?Notification title (FCM data.title).
bodyString?Notification body (FCM data.body).
keyValuePairsMap<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.
extrasMap<String, String>Remaining FCM data fields preserved verbatim — oneLinkParams, oneLinkConfig, and any campaign-specific fields land here.

DeepLinkOpenedPayload:

FieldTypeDescription
routeString?Deep-link target (path or full URL).
sourceDeepLinkOpenedPayload.SourceWhere the deep link originated. Enum values: PUSH, EMAIL, SMS, DEEP_LINK.
keyValuePairsMap<String, Any?>Custom key/value pairs. Same shape as on NotificationOpenedPayload.
extrasMap<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:

FieldTypeDescription
routeString?Deep-link target — path or full URL. nil when the campaign sent no route. Empty strings normalize to nil.
titleString?Notification title (push data.title).
bodyString?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:

FieldTypeDescription
routeString?Deep-link target (path or full URL).
sourceDeepLinkOpenedPayload.SourceWhere 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:

FieldTypeDescription
routestring | undefinedDeep-link target — path or full URL. undefined when the campaign sent no route.
titlestring | undefinedNotification title.
bodystring | undefinedNotification body.
keyValuePairsRecord<string, any>Custom key/value pairs parsed from the JSON-encoded wire field. The SDK parses it for you.
[key: string]anyAdditional notification data fields pass through on the payload (e.g. oneLinkParams, notificationType, channelId, defaultRoute, any campaign-specific keys).

DeepLinkOpenedPayload:

FieldTypeDescription
routestring | undefinedDeep-link target (path or full URL).
source'push' | 'email' | 'sms' | 'deepLink'Where the deep link originated.
keyValuePairsRecord<string, any>Custom key/value pairs. Same shape as on NotificationOpenedPayload.
[key: string]anyAdditional 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 structurePushNotificationReceivedPayload (delivered to both addNotificationOpenedListener and addDeepLinkOpenedListener):

FieldTypeDescription
messageIdstring | nullPush transport-layer id. null if the campaign omitted it.
titlestring | nullNotification title. null if omitted.
bodystring | nullNotification body. null if omitted.
routestring | nullDeep-link route from the campaign. null if the campaign sent no route. addDeepLinkOpenedListener only fires when route is non-null.
keyValuePairsReadonly<Record<string, string>> | nullCustom 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 */ }
}

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);

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

On this page