Swan SDK
Guides

Push notifications

Receive, render, and respond to push notifications — campaigns, channels, deep linking and click acknowledgement.

Push notifications are the most common reason teams adopt Swan. This guide walks through the full lifecycle on every platform:

  1. Provider setup — get device tokens from FCM / APNs to the SDK
  2. Notification channels — register the default channels and (optionally) custom-sound channels
  3. Permission — request notification permission at the right moment
  4. Receive a campaign — what the SDK does automatically when a campaign arrives
  5. Deep linking — route taps into your app's navigation
  6. Click acknowledgement — already automatic, but how to verify

The push architecture concept explains why the SDK is wired this way. This guide is the how.


1. Provider setup

The SDK ships a SwanMessagingService that subclasses FirebaseMessagingService and is declared in the SDK's own AndroidManifest.xml. You don't need to add anything to your manifest.

What you do need:

  1. Add google-services.json from your Firebase console to app/
  2. Apply the Google Services plugin in your app-level build.gradle.kts:
plugins {
    id("com.android.application")
    id("kotlin-android")
    id("com.google.gms.google-services")  // ← this
}
  1. Initialize the SDK in Application.onCreate() (covered in Getting started)

That's it. Token registration, refresh, and message delivery all run inside the SDK.

Image and carousel notifications also require a Notification Service Extension — see iOS — Rich pushes and extensions. The wire-up below covers the plain-push path; without the NSE, iOS strips rich media and displays title + body only.

In your AppDelegate:

import SwanSDK
import UIKit

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        Swan.shared.initialize(appId: "your-app-id")
        UNUserNotificationCenter.current().delegate = self
        application.registerForRemoteNotifications()
        return true
    }

    func application(_ application: UIApplication,
                     didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        Swan.shared.registerAPNsToken(deviceToken)
    }
}

Implement UNUserNotificationCenterDelegate to forward incoming notification taps into the SDK (see Getting started → iOS for the full delegate snippet).

The SDK delivers pushes through React Native Firebase + Notifee. Register all handlers at module scope in index.js (not inside a component) so background and killed-state deliveries fire before the React tree mounts.

// index.js
import messaging from '@react-native-firebase/messaging';
import notifee from '@notifee/react-native';
import {
    createForegroundMessageHandler,
    createBackgroundMessageHandler,
    createNotificationOpenedHandler,
    createNotifeeForegroundHandler,
    createNotifeeBackgroundHandler,
} from '@loyalytics/swan-react-native-sdk';

messaging().onMessage(createForegroundMessageHandler());
messaging().setBackgroundMessageHandler(createBackgroundMessageHandler());

notifee.onForegroundEvent(createNotifeeForegroundHandler());
notifee.onBackgroundEvent(createNotifeeBackgroundHandler());

// iOS carousel taps surface through this Firebase handler — Notifee click
// handlers do not fire for carousel deliveries displayed natively by iOS.
messaging().onNotificationOpenedApp(createNotificationOpenedHandler());

Then enable push during SDK init:

import SwanSDK from '@loyalytics/swan-react-native-sdk';

const sdk = SwanSDK.getInstance('your-app-id', {
    isProduction: true,
    pushNotifications: { enabled: true },
});

On iOS, image and carousel pushes additionally require a Notification Service Extension (and, for carousel, a Notification Content Extension) — see iOS — Rich pushes and extensions.

Web push uses the Web Push (VAPID) standard. The SDK ships a Service Worker; serve dist/sw.js from @loyalytics/swan-web-sdk at /swan-service-worker.js on your origin (see the getting-started guide for build-tool recipes). The SDK reads your per-tenant VAPID public key from the device-register response automatically — no key copy/paste needed:

import { Swan } from '@loyalytics/swan-web-sdk';

Swan.initialize('your-app-id', { production: true });

if (await Swan.isPushSubscribed()) {
  // Already opted in — nothing to do.
} else {
  // Show your "Enable notifications" CTA. Call subscribeToPush() on click.
  const subscribed = await Swan.subscribeToPush();
  // → true on success; false if permission denied or browser unsupported.
}

Swan.addNotificationOpenedListener(({ route, keyValuePairs }) => {
  if (route) router.push(route);
});

Coverage: Chrome / Edge / Firefox (desktop + Android) — full · Safari macOS 13+ — full · iOS Safari 16.4+ — only when installed as a PWA · iOS Safari < 16.4 — not supported (SDK returns false from subscribeToPush gracefully).


2. Notification channels

The SDK pre-registers five default channels on first init:

Channel IDImportance
swan_transactionalHigh
swan_alertsHigh
swan_promotionalDefault
swan_generalDefault
swan_notificationsDefault

The campaign payload chooses the channel by ID — you don't have to do anything unless you want a custom-sound channel (the OS binds the sound to the channel at creation, so you can't change a sound on an existing channel):

import cx.swan.sdk.Swan
import cx.swan.sdk.SWAN_NOTIFICATION_CHANNELS

Swan.createNotificationChannel(
    channelId = "order_updates",
    channelName = "Order updates",
    importance = SWAN_NOTIFICATION_CHANNELS.IMPORTANCE_HIGH,
    soundResName = "order_chime",  // res/raw/order_chime.mp3
)

Call this once at app launch (it's idempotent — calling it again on the same channelId is a no-op).

iOS doesn't have OS-level channels; UNNotificationCategory serves a similar role. The SDK pre-registers the five default categories at init (their ids are exposed as SwanNotificationChannels.transactional, .alerts, .promotional, .general, .default) — these are the same string values as the Android channel ids, so campaign payloads work cross-platform without a remap.

Custom UNNotificationCategory registration (action buttons, custom intents) is a host-app concern — register your categories via UNUserNotificationCenter.current().setNotificationCategories(_:) directly, and have the campaign payload reference your category id.

The SDK pre-registers five default channels on first init. On Android these are OS-level notification channels; on iOS the equivalent UNNotificationCategory entries are registered under the same string IDs so campaign payloads work cross-platform without a remap.

ConstantChannel IDImportance (Android)
SWAN_NOTIFICATION_CHANNELS.TRANSACTIONALswan_transactionalHigh
SWAN_NOTIFICATION_CHANNELS.ALERTSswan_alertsHigh
SWAN_NOTIFICATION_CHANNELS.PROMOTIONALswan_promotionalDefault
SWAN_NOTIFICATION_CHANNELS.GENERALswan_generalDefault
SWAN_NOTIFICATION_CHANNELS.DEFAULTswan_notificationsDefault

The campaign payload chooses the channel by ID — you don't have to do anything unless you want a custom-sound channel (the OS binds the sound to the channel at creation, so you can't change a sound on an existing channel).

import SwanSDK, {
    SWAN_NOTIFICATION_CHANNELS,
} from '@loyalytics/swan-react-native-sdk';

const sdk = SwanSDK.getCurrentInstance();

console.log(SWAN_NOTIFICATION_CHANNELS.TRANSACTIONAL); // 'swan_transactional'

// Custom channel (Android only — no-op on iOS):
await sdk?.createNotificationChannel(
    'Order updates',     // channelName
    4,                   // importance (4 = High)
    'Shipping + delivery',
    'order_updates',     // channelId
    'order_chime'        // sound file in res/raw/
);

Web Notifications API has no channel concept; styling is controlled at the campaign level.


3. Request notification permission

On Android 13+ (API 33) notification permission is runtime, like camera or location. Earlier versions auto-grant.

class MyActivity : AppCompatActivity() {
    override fun onResume() {
        super.onResume()
        Swan.setCurrentActivity(this)   // required for the permission dialog
    }

    override fun onPause() {
        Swan.setCurrentActivity(null)
        super.onPause()
    }

    private fun askForPushPermission() {
        lifecycleScope.launch {
            val granted = Swan.requestNotificationPermission()
            if (granted) {
                // happy path
            } else {
                // user denied — show a soft prompt explaining what they'll miss
            }
        }
    }
}

requestNotificationPermission is a suspend function that returns Boolean. The OS dialog needs an Activity context — register the current Activity via Swan.setCurrentActivity(this) in onResume. If you forget, the call still resolves (returning false) but no dialog will appear.

Best practice: don't ask at first launch — ask after the user has done one meaningful action (signed in, added to cart, completed onboarding). Permission denials are sticky on Android.

let granted = await Swan.shared.requestNotificationPermission()

Same principle: ask after a meaningful action, not at cold launch. iOS denials are also sticky and the user has to go to Settings to flip them back.

const sdk = SwanSDK.getCurrentInstance();
const granted = await sdk?.requestNotificationPermission();

Handles the platform differences (Android 13+ runtime prompt, iOS prompt, older Android auto-grant) internally.

const granted = await Swan.requestPushPermission();

Browser permission UI varies — Chrome / Edge show inline prompts; Safari uses its own model. The SDK exposes the boolean outcome consistently.


4. Receive a campaign

When the Swan backend sends a campaign to a registered device, the SDK:

  1. Receives the data-only payload via the platform's messaging service
  2. Parses the template (basic, carousel-manual, carousel-auto) from the payload
  3. Posts the notification through the OS notification API on the correct channel (Android) / category (iOS)
  4. Awaits user interaction

You do not write a "render notification" callback. That's the SDK's job. What you do wire up is the listener for the user's tap (next section).

If you need to observe incoming pushes (for analytics, debugging, or to add your own in-app UI), subscribe a listener:

Swan.addPushNotificationReceivedListener { payload ->
    Log.d("MyApp", "push received: ${payload.messageId}")
}

Payload structurePushNotificationReceivedPayload:

FieldTypeDescription
messageIdString?FCM RemoteMessage.getMessageId() — the transport-layer id assigned by Firebase. null for synthetic / test pushes.
titleString?Title from the FCM data["title"] field. null if the payload omits it.
bodyString?Body from the FCM data["body"] field. null if the payload omits it.
dataMap<String, String>The raw FCM data map as delivered. String-only per FCM wire enforcement — the SDK does not pre-parse known keys.

The event fires only for foreground deliveries, BEFORE the SDK posts the system notification. Background / killed-state pushes route through the tap path (addNotificationOpenedListener) without firing this event. The event is not buffered — late subscribers do not see prior pushes.

Swan.shared.addPushNotificationReceivedListener { payload in
    print("push received:", payload.messageId ?? "(no id)")
}

Payload structurePushNotificationReceivedPayload:

FieldTypeDescription
messageIdString?Push transport-layer id (APNs id field if present, otherwise any messageId carried in the data payload). nil for synthetic / test pushes.
titleString?Title from the push's data.title. nil if the payload omits it.
bodyString?Body from the push's data.body. nil if the payload omits it.
data[String: String]The raw push data map as delivered. APNs userInfo is flattened to a string map so the shape matches Android. The SDK does not pre-parse known keys.

Fires only for foreground deliveries, BEFORE the SDK posts the system notification. Background / killed-state pushes route through addNotificationOpenedListener. Not buffered — late subscribers do not see prior pushes.

import SwanSDK from '@loyalytics/swan-react-native-sdk';

const sdk = SwanSDK.getCurrentInstance();

sdk?.addEventListener(SwanSDK.EVENTS.PUSH_NOTIFICATION_RECEIVED, (notification) => {
    console.log('push received:', notification);
});

Payload structure — the raw FCM RemoteMessage delivered to the foreground handler. The most common fields:

FieldTypeDescription
messageIdstring | undefinedFCM transport-layer message id.
dataRecord<string, string> | undefinedThe FCM data map as delivered. Includes title, body, route, keyValuePairs (JSON-encoded), and any campaign-specific fields.
notificationobject | undefinedPresent only when the campaign was sent as a notification message rather than data-only; the SDK uses data-only for full control over presentation.

PUSH_NOTIFICATION_RECEIVED fires only for foreground data-only messages received via messaging().onMessage. Background and killed-state deliveries route through the tap path (NOTIFICATION_OPENED).

Swan.addPushNotificationReceivedListener((payload) => {
    console.log('push received:', payload.messageId);
});

Payload structurePushNotificationReceivedPayload:

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.
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.

5. Deep linking on tap

The campaign payload can carry a deepLink (URL). On tap, the SDK opens your app (cold-start or warm) and fires two listeners:

  • addNotificationOpenedListener — gets the full payload, fires for every notification tap regardless of whether there's a deep link
  • addDeepLinkOpenedListener — fires only when there's a deep link URL to route
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Required on cold-start to capture the launching intent
        Swan.handleNotificationIntent(intent)

        Swan.addDeepLinkOpenedListener { payload ->
            payload.route?.let { navController.navigate(it) }
        }
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        // Required on warm-start (notification tap while app is backgrounded)
        Swan.handleNotificationIntent(intent)
    }
}

Cold-start gotcha: the SDK deduplicates by messageId for 30 seconds. Calling handleNotificationIntent from both onCreate and onNewIntent is safe — only one delivery fires for any given message.

Payload structureNotificationOpenedPayload (delivered to addNotificationOpenedListener):

FieldTypeDescription
routeString?Deep-link target — either a path (/products/123) or a full URL (https://example.com/p/1, myapp://offer/abc). null when the campaign sent no route. Empty strings on the wire are normalized 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 keyValuePairs JSON-encoded wire field. Always non-null — empty map when absent, empty, or unparseable. Values are primitives, nested maps, or lists.
extrasMap<String, String>Remaining FCM data fields the SDK does not consume directly. Preserved verbatim — reach for oneLinkParams, oneLinkConfig, or any custom fields here.

DeepLinkOpenedPayload (delivered to addDeepLinkOpenedListener) has route, keyValuePairs, and extras with the same semantics, plus a source: DeepLinkOpenedPayload.Source enum (PUSH, EMAIL, SMS, DEEP_LINK) indicating where the deep link originated.

Swan.shared.addDeepLinkOpenedListener { payload in
    if let route = payload.route { router.navigate(to: route) }
}

// Forward UNUserNotificationCenter callbacks into
// Swan.shared.handleNotificationUserInfo(_:) from your
// UNUserNotificationCenterDelegate to drive cold-start + warm-start.

Payload structureNotificationOpenedPayload (delivered to addNotificationOpenedListener):

FieldTypeDescription
routeString?Deep-link target — either a path or a full URL. nil when the campaign sent no route. Empty strings on the wire are normalized 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 keyValuePairs 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 the SDK does not consume directly. Preserved verbatim — reach for oneLinkParams, oneLinkConfig, or any custom fields here. APNs userInfo is flattened to a string map for parity with FCM.

DeepLinkOpenedPayload (delivered to addDeepLinkOpenedListener) has route, keyValuePairs, and extras with the same semantics, plus a source: DeepLinkOpenedPayload.Source enum (.push, .email, .sms, .deepLink) indicating where the deep link originated.

import SwanSDK from '@loyalytics/swan-react-native-sdk';

useEffect(() => {
    const sdk = SwanSDK.getCurrentInstance();
    const handler = (payload) => {
        if (payload.route) navigation.navigate(payload.route, payload.keyValuePairs);
    };
    const sub = sdk?.addListener(SwanSDK.EVENTS.NOTIFICATION_OPENED, handler);
    return () => sub?.remove();
}, []);

NOTIFICATION_OPENED and DEEP_LINK_OPENED are emitted on the SDK instance and consumed via addListener(event, callback) (returns { remove(): void }). The separate addEventListener API is reserved for FCM-provider events such as PUSH_NOTIFICATION_RECEIVED and TOKEN_RECEIVED.

Payload structureNotificationOpenedPayload:

FieldTypeDescription
routestring | undefinedDeep-link target — either a path or a 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 keyValuePairs wire field. The SDK parses it for you.
[key: string]anyAdditional notification data fields are passed through on the payload (e.g. oneLinkParams, notificationType, channelId, campaign-specific keys).

DeepLinkOpenedPayload has route, keyValuePairs, and the index signature with the same semantics, plus a source: 'push' | 'email' | 'sms' | 'deepLink' indicating where the deep link originated.

Cold-start is handled automatically via the message handlers registered in index.js — see Getting started → React Native.

Swan.addDeepLinkOpenedListener((payload) => {
    if (payload.route) router.push(payload.route);
});

Web "deep links" route through the SDK's service worker on notification click — the bundled Service Worker forwards the click into the page, and your listener fires on the next message tick.

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.

6. Token + permission listeners

Subscribe to push-provider lifecycle events for analytics, debugging, or to mirror the token in your own backend.

val unsubRegistered = Swan.addPushTokenRegisteredListener { event ->
    Log.d("push", "FCM token registered with Swan: ${event.token}")
}

val unsubRefresh = Swan.addPushTokenRefreshListener { event ->
    Log.d("push", "FCM token rotated: ${event.token}")
}

val unsubFailed = Swan.addPushTokenRegistrationFailedListener { event ->
    Log.w("push", "token subscribe failed: ${event.error.message}")
}

Payload structureTelemetryEvent.PushTokenRegistered and TelemetryEvent.PushTokenRefreshed:

FieldTypeDescription
tokenStringThe current FCM token.

addPushTokenRegisteredListener fires on every successful subscribe with the Swan backend (initial registration AND every rotation). It buffers the most recent token, so late subscribers receive the current token synchronously on subscribe.

addPushTokenRefreshListener fires only on FCM token rotation (onNewToken) — not on the initial registration. Useful if you maintain your own copy of the token in a remote backend.

Payload structureTelemetryEvent.PushTokenRegistrationFailed:

FieldTypeDescription
errorThrowableThe underlying failure from the FCM subscribe POST (network error, 4xx, malformed response).

The SDK continues to function — events keep queuing locally and the next onNewToken or Swan.registerFcmToken(...) call retries the subscribe.

let unsubRegistered = Swan.shared.addPushTokenRegisteredListener { payload in
    print("APNs token registered with Swan:", payload.token)
}

let unsubRefresh = Swan.shared.addPushTokenRefreshListener { payload in
    print("APNs token rotated:", payload.token)
}

let unsubFailed = Swan.shared.addPushTokenRegistrationFailedListener { payload in
    print("token subscribe failed:", payload.error.localizedDescription)
}

let unsubPermission = Swan.shared.addPushPermissionListener { event in
    // event is `.permissionGranted` or `.permissionDenied`
}

Payload structurePushTokenRegisteredPayload and PushTokenRefreshPayload:

FieldTypeDescription
tokenStringHex-encoded APNs device token (lowercase, no separators).

addPushTokenRegisteredListener fires on every successful APNs-token registration with the Swan backend (including the initial registration). Re-registering the same token bytes the SDK already holds short-circuits without re-emitting.

addPushTokenRefreshListener fires only when a re-registration replaces a previous token. The initial registration does NOT fire this surface.

Payload structurePushTokenRegistrationFailedPayload:

FieldTypeDescription
errorErrorThe underlying failure from the SDK's POST to /device/push-subscription.

The SDK continues to function — events queue locally and the next Swan.shared.registerAPNsToken(_:) call retries the subscribe.

addPushPermissionListener callback receives a PushLifecycleEvent.permissionGranted or .permissionDenied — fired on every requestNotificationPermission() resolution.

import SwanSDK from '@loyalytics/swan-react-native-sdk';

const sdk = SwanSDK.getCurrentInstance();

sdk?.addEventListener(SwanSDK.EVENTS.TOKEN_RECEIVED, (token) => {
    console.log('FCM token received:', token);
});

sdk?.addEventListener(SwanSDK.EVENTS.TOKEN_REFRESH, (newToken) => {
    console.log('FCM token rotated:', newToken);
});

sdk?.addEventListener(SwanSDK.EVENTS.PERMISSION_CHANGED, (granted) => {
    console.log('permission:', granted ? 'granted' : 'denied');
});

Callback payloads:

Event constantCallback parameter
SwanSDK.EVENTS.TOKEN_RECEIVEDtoken: string — the FCM token generated after permission is granted.
SwanSDK.EVENTS.TOKEN_REFRESHtoken: string — the new FCM token after rotation.
SwanSDK.EVENTS.PERMISSION_CHANGEDgranted: booleantrue if the user granted notification permission, false if denied.

Listeners registered before push initialization are queued and attached when push init completes.

The Web SDK does not expose a separate token-received listener — the Push API subscription is managed through subscribeToPush() / isPushSubscribed() / unsubscribePush(). Permission state changes are observable via the browser's Permissions API:

const status = await navigator.permissions.query({ name: 'notifications' });
status.onchange = () => {
    console.log('permission now:', status.state);
};

7. Click acknowledgement

Click ACK fires automatically — you don't wire it up. When the user taps a notification, the SDK queues a push_clicked event with the originating messageId to /events. Confirm by:

  • Android: grep logcat for [SwanSDK] Notification ACK queued
  • iOS: search the console for [SwanSDK] Notification ACK queued
  • RN: same log marker visible on the metro / logcat / console
  • Web: check the network tab for a /events POST with type: "push_clicked"

If you don't see this log within a second of a tap, file an issue with the messageId and we'll trace it backend-side.


Troubleshooting

SymptomLikely cause
Permission granted but no notifications arriveFCM / APNs token didn't register — grep for [SwanSDK] Device registered successfully
Notifications arrive but look unstyledChannel ID in the campaign payload doesn't match any registered channel — falls back to swan_general
Notification arrives but tap doesn't fire the listenerMissing Swan.handleNotificationIntent(intent) call in onCreate/onNewIntent (Android)
Carousel notification truncates imagesAndroid 13 RemoteViews 2 MB parcel limit — fixed in Android SDK v1.0.0; reproducible on devices that never updated past app v2.6.x
Silent push wakes UIThe payload doesn't have silent: 'true' set — that's a backend campaign config

Reference

On this page