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:
- Provider setup — get device tokens from FCM / APNs to the SDK
- Notification channels — register the default channels and (optionally) custom-sound channels
- Permission — request notification permission at the right moment
- Receive a campaign — what the SDK does automatically when a campaign arrives
- Deep linking — route taps into your app's navigation
- 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:
- Add
google-services.jsonfrom your Firebase console toapp/ - 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
}- 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 ID | Importance |
|---|---|
swan_transactional | High |
swan_alerts | High |
swan_promotional | Default |
swan_general | Default |
swan_notifications | Default |
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.
| Constant | Channel ID | Importance (Android) |
|---|---|---|
SWAN_NOTIFICATION_CHANNELS.TRANSACTIONAL | swan_transactional | High |
SWAN_NOTIFICATION_CHANNELS.ALERTS | swan_alerts | High |
SWAN_NOTIFICATION_CHANNELS.PROMOTIONAL | swan_promotional | Default |
SWAN_NOTIFICATION_CHANNELS.GENERAL | swan_general | Default |
SWAN_NOTIFICATION_CHANNELS.DEFAULT | swan_notifications | Default |
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:
- Receives the data-only payload via the platform's messaging service
- Parses the template (basic, carousel-manual, carousel-auto) from the payload
- Posts the notification through the OS notification API on the correct channel (Android) / category (iOS)
- 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 structure — PushNotificationReceivedPayload:
| Field | Type | Description |
|---|---|---|
messageId | String? | FCM RemoteMessage.getMessageId() — the transport-layer id assigned by Firebase. null for synthetic / test pushes. |
title | String? | Title from the FCM data["title"] field. null if the payload omits it. |
body | String? | Body from the FCM data["body"] field. null if the payload omits it. |
data | Map<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 structure — PushNotificationReceivedPayload:
| Field | Type | Description |
|---|---|---|
messageId | String? | Push transport-layer id (APNs id field if present, otherwise any messageId carried in the data payload). nil for synthetic / test pushes. |
title | String? | Title from the push's data.title. nil if the payload omits it. |
body | String? | 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:
| Field | Type | Description |
|---|---|---|
messageId | string | undefined | FCM transport-layer message id. |
data | Record<string, string> | undefined | The FCM data map as delivered. Includes title, body, route, keyValuePairs (JSON-encoded), and any campaign-specific fields. |
notification | object | undefined | Present 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 structure — PushNotificationReceivedPayload:
| 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. |
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. |
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 linkaddDeepLinkOpenedListener— 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 structure — NotificationOpenedPayload (delivered to addNotificationOpenedListener):
| Field | Type | Description |
|---|---|---|
route | String? | 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. |
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 keyValuePairs JSON-encoded wire field. Always non-null — empty map when absent, empty, or unparseable. Values are primitives, nested maps, or lists. |
extras | Map<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 structure — NotificationOpenedPayload (delivered to addNotificationOpenedListener):
| Field | Type | Description |
|---|---|---|
route | String? | 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. |
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 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 structure — NotificationOpenedPayload:
| Field | Type | Description |
|---|---|---|
route | string | undefined | Deep-link target — either a path or a 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 keyValuePairs wire field. The SDK parses it for you. |
[key: string] | any | Additional 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 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. |
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 structure — TelemetryEvent.PushTokenRegistered and TelemetryEvent.PushTokenRefreshed:
| Field | Type | Description |
|---|---|---|
token | String | The 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 structure — TelemetryEvent.PushTokenRegistrationFailed:
| Field | Type | Description |
|---|---|---|
error | Throwable | The 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 structure — PushTokenRegisteredPayload and PushTokenRefreshPayload:
| Field | Type | Description |
|---|---|---|
token | String | Hex-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 structure — PushTokenRegistrationFailedPayload:
| Field | Type | Description |
|---|---|---|
error | Error | The 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 constant | Callback parameter |
|---|---|
SwanSDK.EVENTS.TOKEN_RECEIVED | token: string — the FCM token generated after permission is granted. |
SwanSDK.EVENTS.TOKEN_REFRESH | token: string — the new FCM token after rotation. |
SwanSDK.EVENTS.PERMISSION_CHANGED | granted: boolean — true 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
/eventsPOST withtype: "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
| Symptom | Likely cause |
|---|---|
| Permission granted but no notifications arrive | FCM / APNs token didn't register — grep for [SwanSDK] Device registered successfully |
| Notifications arrive but look unstyled | Channel ID in the campaign payload doesn't match any registered channel — falls back to swan_general |
| Notification arrives but tap doesn't fire the listener | Missing Swan.handleNotificationIntent(intent) call in onCreate/onNewIntent (Android) |
| Carousel notification truncates images | Android 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 UI | The payload doesn't have silent: 'true' set — that's a backend campaign config |
Reference
- Push architecture — how the wire works
- Deep linking guide — handling non-push deep links and OneLink params
- API reference: Android