@releva-ai/sdk-react-native
v0.4.0
Published
Releva.ai SDK for React Native apps
Downloads
678
Readme
Releva React Native SDK
This package offers an easy way to integrate Releva's AI-powered e-commerce personalization platform into your mobile app built on React Native.
Features
E-commerce Personalization
- Product Recommendations: AI-powered product suggestions with real-time personalization
- Dynamic Content: Personalized banners, stories, and content blocks based on user behavior
- Advanced Filtering: Complex product filtering with nested AND/OR logic, price ranges, custom fields
- Smart Search: Search tracking with result optimization and recommendation integration
Mobile Tracking & Analytics
- Screen Tracking: Integration with React Navigation for seamless route/screen tracking
- E-commerce Events: Product views, cart changes, checkout tracking, search analytics
- Custom Events: Flexible event system for business-specific tracking needs
Push Notifications
- Firebase Integration: Complete FCM push notification system with data-only payloads
- Structured Navigation:
RelevaNotificationActiontype with parsed target, screen, URL, and parameters - Engagement Analytics: Delivered, opened, dismissed tracking
- Deep Linking: Route users directly to screens or inbox messages from notification taps
- Cross-Platform: Android and iOS device support
App Inbox
- Persistent Messages: In-app message centre where messages survive until read, deleted, or expired
- Rich Content: Messages rendered with Unlayer design JSON, fully personalized per user
- Real-Time Sync: Silent push notifications trigger automatic inbox refresh via
handleForegroundMessage - Deep Link to Message:
getMessageById()enables navigating directly to a specific inbox message from a push notification - Optimistic Updates: Mark read, mark all read, and delete with instant UI feedback and rollback on failure
- Cursor Pagination: Efficient infinite scroll with server-side cursor pagination
- Local Caching: Messages cached locally for instant display on return visits
- Smart Refresh:
refreshIfStale()avoids unnecessary network requests on app resume (5-minute TTL)
NPS Surveys
- Server-Driven: NPS survey configuration and targeting managed from the Releva dashboard
- Custom Events: Trigger surveys or cancel pending surveys based on app events
Flexible Configuration
- Modular Setup: Enable only needed features via
RelevaConfigPresets - Endpoint Override: Use
setEndpointOverride()for local development with ngrok or custom API endpoints - Offline Support: Engagement events are queued and sent when connection is available
Installation
Add the Releva React Native SDK to your project:
npm install @releva-ai/sdk-react-native
# or
yarn add @releva-ai/sdk-react-nativePeer Dependencies
Install the required peer dependencies:
npm install @react-native-async-storage/async-storage \
@react-native-community/netinfo \
@notifee/react-native \
@react-native-firebase/app \
@react-native-firebase/messaging@react-native-firebase/messaging is used by your app code (token retrieval and message handlers) and, on iOS only, by the notifee adapter, which reads it (v20+) to see taps on the notifications iOS draws itself — see iOS: notifications iOS draws itself. It is still an optional peer: the SDK core never imports it, and the adapter loads it lazily. @notifee/react-native is only needed when you use the notifee notification adapter (see below); apps that keep expo-notifications do not install it.
Notification adapters
The SDK core does not depend on any notification library. Displaying Releva's data-only pushes and reporting taps is done by a notification adapter, shipped as subpath imports so only the one you use ends up in your bundle:
| Adapter | Import | Stack | Notes |
|---|---|---|---|
| notifee (recommended) | @releva-ai/sdk-react-native/notifee | @react-native-firebase/messaging + @notifee/react-native | Full feature set on Android and iOS: rich images, action buttons, engagement, inbox sync. On iOS the image and button appear on the notifications the adapter draws (app in the foreground); iOS draws the rest itself — see iOS: notifications iOS draws itself |
| expo-notifications | @releva-ai/sdk-react-native/expo | expo-notifications (+ expo-task-manager) | Title + body notifications with button actions on both platforms; no big-picture images on Android. iOS push requires APNs import enabled on your Releva account. See Using expo-notifications |
Register the adapter once, at module top level (for example in index.js), before any message handler can run:
// index.js
import { EngagementTrackingService } from '@releva-ai/sdk-react-native';
import { notifeeNotificationAdapter } from '@releva-ai/sdk-react-native/notifee';
EngagementTrackingService.setNotificationAdapter(notifeeNotificationAdapter);Alternatively pass it as adapter to enablePushEngagementTracking(). Tracking, banners, stories, NPS and the inbox do not need an adapter at all.
Native-display tokens and the notifee adapter (Android): if your backend registers an Android token with
notificationStyle: 'notification', it sends the message as an FCM notification block and the data payload. While your app is backgrounded or killed, Android draws that block itself and the notifee adapter correctly stands aside rather than drawing a duplicate. While your app is in the foreground, FCM does not draw the block at all — the message goes toonMessage/handleForegroundMessageinstead — so the adapter still draws it there; this is the only state in which the user sees anything. On Android a tap on a notification the platform drew natively (backgrounded/killed case) is invisible to this adapter — notifee's press events andgetInitialNotification()only cover notifications notifee itself posted. For these tokens, wire react-native-firebase'sonNotificationOpenedApp()andgetInitialNotification()yourself if you need tap tracking oronNotificationTappedto fire on them. On iOS you do not need to: the adapter does that wiring itself, for every push — see the next section.
Note: All Firebase examples in this document use the modular API of
@react-native-firebase/*v22+ (getMessaging(),getToken(),onMessage(), ...). The namespaced API (import messaging from '@react-native-firebase/messaging'; messaging().getToken()) was removed in v26, which is what a fresh install resolves today.
For iOS, install CocoaPods:
cd ios && pod install && cd ..Firebase Setup (Required for Push Notifications)
1. Install Firebase dependencies
npm install @react-native-firebase/app @react-native-firebase/messaging2. Android Configuration
Add your google-services.json to android/app/.
In android/build.gradle:
buildscript {
dependencies {
classpath 'com.google.gms:google-services:4.4.0'
}
}In android/app/build.gradle:
apply plugin: 'com.google.gms.google-services'3. iOS Configuration
Add your GoogleService-Info.plist to the iOS project via Xcode.
Initialize the Releva Client
import { RelevaClient, NavigationService } from '@releva-ai/sdk-react-native';
import {
AuthorizationStatus,
getMessaging,
getToken,
onTokenRefresh,
requestPermission,
} from '@react-native-firebase/messaging';
import { Platform } from 'react-native';
import { v4 as uuidv4 } from 'uuid';
// Initialize Releva Client
// realm - use '' unless instructed otherwise by your account manager
// accessToken - use the access token provided by your account manager
const client = new RelevaClient('', '<yourAccessToken>');
async function initializeSDK() {
// Set device ID based on your current logic for device tracking
await client.setDeviceId('<deviceId>');
// If a user has registered or logged in, provide a profileId
await client.setProfileId('<profileId>');
// Enable app push notification engagement metrics collection
// IMPORTANT: ensure that you have set the profileId and deviceId first!
await client.enablePushEngagementTracking({
onNotificationTapped: (action) => {
// Handle navigation - see "Push Notification Engagement Tracking" below
},
});
// Register FCM token
await registerPushToken();
// Do NOT call client.checkInitialNotification() here - the navigation ref
// isn't ready yet at this point. See "Background Messages and Cold Start"
// below for where it belongs.
}
async function registerPushToken() {
const messaging = getMessaging();
const authStatus = await requestPermission(messaging);
const enabled =
authStatus === AuthorizationStatus.AUTHORIZED ||
authStatus === AuthorizationStatus.PROVISIONAL;
if (enabled) {
const token = await getToken(messaging);
if (token) {
const deviceType = Platform.OS === 'ios' ? 'ios' : 'android';
await client.registerPushToken(deviceType, token);
}
// Handle token refresh
onTokenRefresh(messaging, async (newToken) => {
const deviceType = Platform.OS === 'ios' ? 'ios' : 'android';
await client.registerPushToken(deviceType, newToken);
});
}
}React Navigation Integration
import { NavigationContainer, createNavigationContainerRef } from '@react-navigation/native';
import { NavigationService } from '@releva-ai/sdk-react-native';
const navigationRef = createNavigationContainerRef();
// Optional: store the navigation ref in NavigationService so your
// onNotificationTapped callback can access it via
// NavigationService.navigationRef.current
NavigationService.setNavigationRef(navigationRef);
function App() {
return (
<NavigationContainer ref={navigationRef}>
{/* Your screens */}
</NavigationContainer>
);
}Endpoint Override (Local Development)
For local development or testing with a custom API endpoint (e.g. ngrok):
// Set a custom endpoint (overrides realm-based URL)
client.setEndpointOverride('https://your-ngrok-url.ngrok.io');
// Clear override to revert to default
client.setEndpointOverride(null);The override applies to all API calls including inbox. Pass null to clear and revert to the realm-based default.
Warning: Non-HTTPS endpoints will log a warning. Use HTTPS in production.
Profile Management and User Logout
Important: Handling User Logout
When a user logs out, prevent merging the logged-in user's profile with the anonymous profile:
// When user logs out, generate a new anonymous profile ID
const newAnonymousProfileId = uuidv4();
// Set the new profile ID with skipMerge set to true
await client.setProfileId(newAnonymousProfileId, true);
// Re-register the firebase push token to the new profile
const token = await getToken(getMessaging());
if (token) {
const deviceType = Platform.OS === 'ios' ? 'ios' : 'android';
await client.registerPushToken(deviceType, token);
}Default Behavior (when logging in or switching users):
// Normal profile change - previous profile will be merged
await client.setProfileId(loggedInUserId);Push Notification Engagement Tracking
Option 1: Use the SDK's built-in tracking (Recommended)
The SDK handles all Firebase messaging hooks and engagement tracking. Your app
provides a required onNotificationTapped callback that receives a structured
RelevaNotificationAction — your app decides how to navigate. This keeps
the SDK compatible with any navigation approach (React Navigation, Expo Router,
custom routing, etc.).
import { RelevaNotificationAction, NavigationService } from '@releva-ai/sdk-react-native';
import { Linking } from 'react-native';
await client.enablePushEngagementTracking({
onNotificationTapped: (action: RelevaNotificationAction) => {
// action.target - 'screen', 'url', 'inbox', or null
// action.screen - app-defined screen name (when target is 'screen')
// action.url - URL to open (when target is 'url')
// action.parameters - parsed parameters from navigate_to_parameters JSON
// Example using React Navigation (adapt to your routing solution):
const navRef = NavigationService.navigationRef;
if (action.target === 'inbox') {
navRef?.current?.navigate('Inbox', action.parameters);
} else if (action.target === 'url' && action.url) {
Linking.openURL(action.url);
} else if (action.target === 'screen' && action.screen) {
// action.screen is the free-form screen name configured in the
// Releva dashboard — map it to your app's screen names.
navRef?.current?.navigate(action.screen, action.parameters);
}
},
});Background Messages and Cold Start (required)
Releva sends data-only FCM messages, so the operating system does not display them by itself. Two pieces of wiring in your app are required for push to work when the app is not in the foreground:
1. Background message handler - must be registered at module top level (for example in index.js), outside any React component, so it also runs when the app is backgrounded or killed:
// index.js
import { getMessaging, setBackgroundMessageHandler } from '@react-native-firebase/messaging';
import { EngagementTrackingService } from '@releva-ai/sdk-react-native';
import { notifeeNotificationAdapter } from '@releva-ai/sdk-react-native/notifee';
// The adapter must be registered here too: on a cold start this file runs
// before your app initializes, and the handler needs it to display anything.
EngagementTrackingService.setNotificationAdapter(notifeeNotificationAdapter);
setBackgroundMessageHandler(getMessaging(), async (remoteMessage) => {
await EngagementTrackingService.handleBackgroundMessage(remoteMessage.data ?? {});
});Without this, notifications delivered while the app is in the background or killed are never displayed on Android. (iOS draws those itself — see below.)
2. Cold-start tap check - when the user taps a notification while the app is killed, the tap arrives before your JavaScript has initialized. After enablePushEngagementTracking() has been called and your navigation container is ready, call:
await client.checkInitialNotification();It resolves immediately when the app was not launched from a notification. When it was, the tap is tracked and your onNotificationTapped callback fires exactly as for a warm tap.
iOS: notifications iOS draws itself
Releva sends every visible iOS push with an aps.alert, so while your app is backgrounded or killed iOS draws the notification itself and your background handler is not called for it; the notifee adapter draws only while the app is in the foreground (through handleForegroundMessage). A tap on an iOS-drawn notification never reaches notifee — its press events and getInitialNotification() cover only what notifee posted — so on iOS the adapter picks it up from @react-native-firebase/messaging itself. There is nothing to wire:
- App killed:
client.checkInitialNotification()hands the tap over, exactly as it does a cold-start tap on a notification the adapter drew, so it routes once your navigation is ready. A tap reported before that call is held for it rather than routed while the app is still starting. - App backgrounded: the tap arrives through
onNotificationOpenedApp(), andonNotificationTappedfires straight away. - Either way the click is reported (
callbackUrl), and each tap is handled once even though react-native-firebase reports some of them both ways.
If your app forwards every Releva tap from its own onNotificationOpenedApp() handler to EngagementTrackingService.handleNotificationOpened() on iOS, remove that: the adapter's own onNotificationOpenedApp() subscription (set up inside enablePushEngagementTracking()) already routes and counts it, so forwarding it as well counts every warm tap twice. Skip Releva's in your own handler with isRelevaMessage(remoteMessage.data) instead.
If your app also reads getInitialNotification() for pushes of its own: react-native-firebase hands the message that launched the app to its first caller only, and on iOS checkInitialNotification() is one. So read yours before enablePushEngagementTracking(), before anything in the SDK can read it, and hand a Releva message to the SDK only after checkInitialNotification() has resolved, once onNotificationTapped is registered. Even then, hand it over only if the SDK has not routed a tap itself: the SDK can still see that same tap another way (from notifee, or from react-native-firebase's opened event), and handleNotificationOpened() takes no message id, so it cannot tell a tap the SDK already handled from a new one. When checkInitialNotification() routes a tap, onNotificationTapped has fired by the time it resolves:
import { getMessaging, getInitialNotification } from '@react-native-firebase/messaging';
import { EngagementTrackingService, isRelevaMessage } from '@releva-ai/sdk-react-native';
// Before client.enablePushEngagementTracking():
const launchMessage = await getInitialNotification(getMessaging());
let sdkRoutedATap = false;
await client.enablePushEngagementTracking({
onNotificationTapped: (action) => {
sdkRoutedATap = true;
// navigate
},
});
// ...
// Where you call checkInitialNotification(), once your navigation is ready
// (see "Background Messages and Cold Start"):
await client.checkInitialNotification();
if (launchMessage?.data && isRelevaMessage(launchMessage.data)) {
if (!sdkRoutedATap) {
await EngagementTrackingService.handleNotificationOpened(launchMessage.data); // routes and counts it
}
} else if (launchMessage) {
// one of your own pushes
}The flag is a guard, not the SDK's own deduplication (which goes by message id), and one rare launch gets past it: the app is sent to the background before checkInitialNotification(). The SDK routes the tap it was holding at that moment, but onNotificationTapped fires only after the click has been reported. If that report is still in flight when the check resolves, the tap is handled twice.
What an iOS notification can show depends on who drew it:
| | Drawn by the adapter (app in the foreground) | Drawn by iOS (app backgrounded or killed) |
|---|---|---|
| Action button (button) | Yes — the adapter registers a notification category per button label and names it on the notification; pressing it routes like a tap on the notification | No. iOS draws the push as sent, and a per-message label would need a notification service or content extension in your app (native code) |
| Image (imageUrl) | JPEG, PNG or GIF, as an attachment. Not WebP: iOS notification attachments cannot be WebP, so the adapter leaves the picture off and logs [Releva] image not attached on iOS: … is WebP … instead of drawing a silently text-only notification. It goes by the URL (a .webp path, or a query value of webp such as ?format=webp), so a URL that does not say its format is not caught | Only if your app ships a notification service extension that downloads it (Releva sends it as fcm_options.image and image-url, with mutable-content: 1); the same format limit applies |
To have campaign images on iPhones, serve them as JPEG or PNG: Android draws WebP either way, iOS never does.
Using expo-notifications instead of Firebase messaging + notifee
Apps that already run an expo-notifications push stack can keep it and use the Expo adapter. Read the limits first:
- iOS push requires APNs import.
expo-notificationsexposes the device's FCM registration token on Android, which Releva can address directly. On iOS it exposes an APNs token; registering it (getDevicePushToken()+client.registerPushToken('ios', token, { tokenType: 'apns', bundleId })) requires APNs import enabled on your Releva account (ask your account manager) so the backend can convert it to an FCM token. Until then, Releva push, push engagement and silent inbox sync are not available on iOS with this adapter. Every non-push SDK feature works on both platforms regardless. - No picture on Android.
expo-notificationscannot render a big-picture image on Android, soimageUrlis ignored there (it shows on iOS). Thebuttonpayload renders as a real notification action on both platforms. - Background delivery goes through
expo-task-manager. Requires a development build (not Expo Go). Verify killed-app delivery on a real device. - Your existing
expo-notificationslisteners andsetNotificationHandlerstay as they are; the adapter never callssetNotificationHandler. Filter Releva messages out of your own handlers withisRelevaMessage(data).
npx expo install expo-notifications expo-task-manager
# expo-application is only needed for the iOS bundleId lookup below
npx expo install expo-application// index.js
import * as Notifications from 'expo-notifications';
import * as TaskManager from 'expo-task-manager';
import { EngagementTrackingService } from '@releva-ai/sdk-react-native';
import { expoNotificationsAdapter, extractRelevaPayload } from '@releva-ai/sdk-react-native/expo';
// Must run before defineTask()'s callback can fire - it reads this adapter
// on a cold start, before the rest of the app has initialized.
EngagementTrackingService.setNotificationAdapter(expoNotificationsAdapter);
// Background data-only messages (app backgrounded or killed)
const RELEVA_BACKGROUND_TASK = 'RELEVA_BACKGROUND_NOTIFICATION_TASK';
TaskManager.defineTask(RELEVA_BACKGROUND_TASK, async ({ data }) => {
const payload = extractRelevaPayload(data);
if (payload) await EngagementTrackingService.handleBackgroundMessage(payload);
});
// RN's index.js has no top-level await, so a rejection here (task name
// collision, missing expo-task-manager config plugin) must be caught
// explicitly or it becomes an unhandled promise rejection at startup.
Notifications.registerTaskAsync(RELEVA_BACKGROUND_TASK).catch((e) =>
console.warn('[Releva] Failed to register background notification task:', e),
);// App initialization
import * as Notifications from 'expo-notifications';
import { EngagementTrackingService, isRelevaMessage } from '@releva-ai/sdk-react-native';
import { getDevicePushToken, isRelevaLocalNotification } from '@releva-ai/sdk-react-native/expo';
// bundleId comes from expo-application's Application.applicationId
// (`npx expo install expo-application`).
import * as Application from 'expo-application';
await client.enablePushEngagementTracking({ onNotificationTapped });
// Call client.checkInitialNotification() once your navigation container is
// ready - see "Background Messages and Cold Start" above.
// Android: FCM token; iOS: APNs token. Registering the APNs token requires
// the APNs import feature on your Releva account (ask your account manager).
const devicePushToken = await getDevicePushToken();
if (devicePushToken?.tokenType === 'fcm') {
await client.registerPushToken('android', devicePushToken.token);
} else if (devicePushToken?.tokenType === 'apns') {
await client.registerPushToken('ios', devicePushToken.token, {
tokenType: 'apns',
bundleId: Application.applicationId!,
});
}
// Foreground messages. addNotificationReceivedListener also fires for the
// local notification that displayNotification() schedules below, so it must
// be excluded here or every foreground push re-displays itself forever -
// isRelevaLocalNotification() is exactly that check.
Notifications.addNotificationReceivedListener((notification) => {
const data = (notification.request.content.data ?? {}) as Record<string, any>;
if (isRelevaMessage(data) && !isRelevaLocalNotification(data)) {
EngagementTrackingService.handleForegroundMessage(data);
}
});Checking Releva Messages
// Check whether a notification data payload originated from Releva
const isReleva = RelevaClient.isRelevaMessage(remoteMessage.data);Inbox Sync from Silent Push
When a new inbox message is delivered, the server sends a silent push with inbox_sync: "true". Set up a foreground message listener to handle these signals:
import { getMessaging, onMessage } from '@react-native-firebase/messaging';
import { EngagementTrackingService } from '@releva-ai/sdk-react-native';
onMessage(getMessaging(), async (remoteMessage) => {
await EngagementTrackingService.handleForegroundMessage(
remoteMessage.data ?? {},
);
});When the app resumes from background, the inbox automatically refreshes if the cache is stale (older than 5 minutes). This serves as a reliable fallback when silent push doesn't arrive (battery optimization, iOS background limits, etc.).
Send Push Requests
Set Wishlist
import { WishlistProduct, emptyCustomFields } from '@releva-ai/sdk-react-native';
const product: WishlistProduct = { id: '<productId>', custom: emptyCustomFields() };
await client.setWishlist([product]);Set Cart
import { CartProduct, Cart, createActiveCart, emptyCustomFields } from '@releva-ai/sdk-react-native';
const product: CartProduct = {
id: '<productId>',
price: 29.99,
quantity: 1,
custom: emptyCustomFields(),
};
await client.setCart(createActiveCart([product]));High-level Tracking Methods
Screen View Tracking
const response = await client.trackScreenView({
screenToken: 'home_screen',
productIds: ['prod1', 'prod2', 'prod3'],
categories: ['electronics', 'phones'],
locale: 'en',
currency: 'USD',
});Product View Tracking
const response = await client.trackProductView({
screenToken: 'product_detail',
productId: 'product-123',
categories: ['electronics', 'phones'],
locale: 'en',
currency: 'USD',
});Search Tracking
import { SimpleFilter, NestedFilter } from '@releva-ai/sdk-react-native';
const response = await client.trackSearchView({
screenToken: 'search_results',
query: 'red running shoes',
resultProductIds: ['prod1', 'prod2', 'prod3'],
filter: NestedFilter.and([
SimpleFilter.priceRange(50, 200),
SimpleFilter.brand('Nike'),
SimpleFilter.color('red'),
]),
locale: 'en',
currency: 'USD',
});Checkout Success Tracking
import { createPaidCart } from '@releva-ai/sdk-react-native';
const orderedCart = createPaidCart(cartProducts, orderId);
// The SDK identifies the user solely by the profileId set via setProfileId().
// Contact details and other profile attributes are never sent from the client.
const response = await client.trackCheckoutSuccess({
screenToken: 'checkout_success',
orderedCart,
locale: 'en',
currency: 'USD',
});Banners
Wrap your screen content with BannerDisplayWidget to enable banner display:
import { BannerDisplayWidget } from '@releva-ai/sdk-react-native';
function HomeScreen() {
const client = getRelevaClient();
return (
<BannerDisplayWidget
targetSelector="#home-content"
client={client}
onLinkTap={(url) => Linking.openURL(url)}
>
{/* Your screen content */}
</BannerDisplayWidget>
);
}Banner Triggers
- immediately: Shows as soon as the screen loads
- delaySeconds: Shows after a specified delay
- scrollPercentage: Shows when user scrolls to a certain percentage
- cartChanged: Shows when cart is modified
- wishlistChanged: Shows when wishlist is modified
Orientation: the SDK's full-screen overlays — popup/flyout banners, stories and the NPS sheet — rotate with your app. They allow every orientation and leave the choice to your app's own settings (Info.plist, or any orientation lock you apply), so they no longer hold an iPhone app in portrait while they are showing. On iOS they keep their content clear of the status bar, the sensor housing and the home indicator in either orientation.
Stories
Wrap your screen content with StoryDisplayWidget:
import { StoryDisplayWidget } from '@releva-ai/sdk-react-native';
function HomeScreen() {
return (
<StoryDisplayWidget
client={client}
onLinkTap={(url) => Linking.openURL(url)}
>
{/* Your screen content */}
</StoryDisplayWidget>
);
}App Inbox
Initialize the Inbox
await client.setProfileId('<profileId>');
await client.initializeInbox();Cleanup: Call
client.dispose()when your root component unmounts (e.g. in auseEffectcleanup). This stops the inbox's AppState listener and the engagement tracking batch timer, preventing leaks in development hot-reloads and during testing.useEffect(() => { client.initializeInbox(); return () => client.dispose(); }, []);
Access Inbox State
import { InboxService } from '@releva-ai/sdk-react-native';
const state = InboxService.state;
console.log(state.messages); // InboxMessage[]
console.log(state.unreadCount); // number
console.log(state.isLoading); // boolean
// Listen for changes
InboxService.addListener((newState) => {
// Update your UI
});Refresh and Pagination
await InboxService.refresh(); // Fetch first page + unread count
await InboxService.loadMore(); // Fetch next page (cursor pagination)
await InboxService.refreshIfStale(); // Only refresh if cache is older than 5 minutesMark as Read and Delete
All operations use optimistic updates — the UI updates instantly while the API call happens in the background. If the API call fails, the change is rolled back.
await InboxService.markAsRead(message.id);
await InboxService.markAllAsRead();
await InboxService.deleteMessage(message.id);Track Message Action
Fire-and-forget tracking for when a user interacts with a message link or button:
await InboxService.trackAction(message.id);Navigate to a Specific Inbox Message (from Push)
When a push notification targets a specific inbox message, use getMessageById to look it up by its template ID:
import { InboxService, RelevaNotificationAction } from '@releva-ai/sdk-react-native';
await client.enablePushEngagementTracking({
onNotificationTapped: async (action: RelevaNotificationAction) => {
if (action.target === 'inbox') {
const inboxMessageId = Number(action.parameters?.inboxMessageId);
if (inboxMessageId) {
const message = await InboxService.getMessageById(inboxMessageId);
if (message) {
if (!message.read) await InboxService.markAsRead(message.id);
navigation.navigate('InboxMessage', { messageId: message.id });
return;
}
}
navigation.navigate('Inbox');
}
},
});getMessageById calls refreshIfStale() internally before searching, so in the typical flow where a silent push has already refreshed the inbox, no extra network request is made.
Render Message Content
import { InboxMessageWidget } from '@releva-ai/sdk-react-native';
<InboxMessageWidget
message={inboxMessage}
onLinkTap={(url) => Linking.openURL(url)}
/>Handle Inbox Sync Signal
See Inbox Sync from Silent Push above for setting up the foreground message listener that keeps the inbox in sync.
NPS Surveys
Setup
Wrap your app with NpsOverlayWidget:
import { NpsOverlayWidget } from '@releva-ai/sdk-react-native';
function App() {
return (
<NpsOverlayWidget
onSubmit={async (token, score, comment) => {
await client.submitNpsResponse({ token, score, comment });
}}
>
<NavigationContainer>
{/* Your screens */}
</NavigationContainer>
</NpsOverlayWidget>
);
}Firing custom events
// Trigger NPS after checkout
client.trackEvent('checkout_complete');
// Cancel pending NPS if user enters a sensitive flow
client.trackEvent('checkout_started');Configuration Options
import { RelevaClient, RelevaConfigPresets } from '@releva-ai/sdk-react-native';
// Full functionality (default)
new RelevaClient('', 'token');
// Custom configuration
new RelevaClient('', 'token', RelevaConfigPresets.trackingOnly());Request timeout and retries
Every RelevaClient request (tracking, banner/story events, push token registration, NPS
submissions) is aborted after requestTimeoutMs and retried maxRetryAttempts times on a
transport failure (the abort included) or a 5xx — never on a 4xx. The defaults are the Kotlin
SDK's; override them only if your network warrants it.
InboxService (client.inbox) is on this path too, as of 0.3.4: markAsRead, markAllAsRead,
deleteMessage, trackAction, refresh/loadMore and the unread-count fetch all get the same
deadline, the same retries and the same log lines. Message ids are collapsed to {id} in those
lines (POST /api/v0/inbox/messages/{id}/read -> 200 in 61ms) and query strings are not logged
at all, so neither a message id nor the ?userId= profile id reaches the log. The inbox picks
the path up from client.initializeInbox(); calling InboxService.initialize() yourself leaves
it on a bare fetch().
new RelevaClient('', 'token', {
...RelevaConfigPresets.full(),
requestTimeoutMs: 30000, // default
maxRetryAttempts: 3, // default — 4 attempts in total
transportRetryDelayMs: 1000, // default
serverErrorRetryDelayMs: 2000, // default
});Debug layout logging (automated testing only)
enableDebugLayoutLogging makes StoryViewerWidget log the absolute on-screen bounds of its
controls, so a UI-automation pass can tap them without reading the screen:
[Releva] layout releva-story-close x=339 y=44 width=24 height=24 px=1018,131 density=3
[Releva] layout releva-story-slide-0 x=0 y=60 width=375 height=700 px=0,180 density=3
[Releva] layout releva-story-action x=24 y=700 width=327 height=48 px=72,2100 density=3The story viewer's progress bar animates for the life of every slide, so the window never goes
idle and uiautomator dump refuses it — the releva-story-* identifiers exist but cannot be
looked up while a story is open. x/y/width/height are measureInWindow's values, which
on Android are density-independent points, not the physical pixels adb shell input tap <x> <y>
takes — a density-3 device that logs x=339 is at 1017 physically. px is that same position
already scaled by PixelRatio.get() (the density also logged), so adb shell input tap can be
fed px directly. Each control reports itself once it is laid out, and the slide and action
controls report again whenever the story advances.
Leave it off in production. It is off by default and in every preset, and while it is off no
measurement is taken and no line is built. Positions only: no slide content, no identifier
beyond the control's own testID.
new RelevaClient('', 'token', {
...RelevaConfigPresets.full(),
enableDebugLayoutLogging: true,
});API Reference
RelevaClient Methods
Configuration
setDeviceId(deviceId: string)- Set unique device identifiersetProfileId(profileId: string, skipMerge?: boolean)- Set user profile identifier. PassskipMerge: trueon logout to prevent merging with the previous profile.setAppVersion(version: string)- Set app version for analytics contextsetEndpointOverride(url: string | null)- Override API endpoint URL for all calls including inbox. Passnullto clear.setCart(cart: Cart)- Update user's cartsetWishlist(products: WishlistProduct[])- Update user's wishlistclearCartStorage()- Clear stored cart data
Push Notifications
enablePushEngagementTracking({ onNotificationTapped, adapter? })- Enable push notification tracking with required tap callback. The app handles all navigation.adaptermay be passed here instead ofEngagementTrackingService.setNotificationAdapter().checkInitialNotification()- Handle a notification tap that launched the app from a killed state (tracks engagement and firesonNotificationTapped). Call afterenablePushEngagementTracking()once navigation is ready.registerPushToken(type: DeviceType, token: string)- Register FCM token with Releva
Tracking
push(request: PushRequest)- Send custom tracking requesttrackScreenView(options)- Track screen viewstrackProductView(options)- Track product viewstrackSearchView(options)- Track search queriestrackCheckoutSuccess(options)- Track successful purchasestrackCustomEvent(options)- Track custom eventstrackEvent(eventName: string)- Fire a named event (for NPS triggers)
Inbox
initializeInbox()- Initialize the inbox service (call aftersetProfileId)inbox- Property returning theInboxServicesingleton
Analytics
bannerImpression(banner)- Track banner displaybannerAction(banner, action?)- Track banner interactionstoryImpression(story)- Track story displaystoryAction(story, action, slideId?)- Track story interaction
NPS
submitNpsResponse(options)- Submit NPS survey response
Utility
static isRelevaMessage(data)- Check if a notification data payload originated from Releva (matchesclick_actionstarting withRELEVA_)dispose()- Release resources held by the client
InboxService Methods
state- CurrentInboxState(messages, unreadCount, isLoading, hasMore, nextCursor, lastFetchTime)addListener(listener)/removeListener(listener)- Subscribe to state changesinitialize(options)- Initialize with access token, realm, and optional endpoint overridesetEndpointOverride(url)- Update the API endpoint for inbox callsrefresh()- Fetch first page of messages and unread countloadMore()- Fetch next page using cursor paginationmarkAsRead(messageId)- Mark a single message as read (optimistic)markAllAsRead()- Mark all messages as read (optimistic)deleteMessage(messageId)- Delete a message (optimistic)trackAction(messageId)- Track message interaction (fire-and-forget)getMessageById(inboxMessageId)- Look up a message by template ID (refreshes if stale)handleSyncSignal()- Trigger a full inbox refresh (called by silent push handler)refreshIfStale()- Refresh only if cache is older than 5 minutesdispose()- Remove lifecycle observer
EngagementTrackingService Methods
setNotificationAdapter(adapter)- Register the notification adapter (call at module top level). Required before push can be displayed.notificationAdapter- The registered adapter ornullstatic isRelevaMessage(data)- Check if notification data is from RelevacheckInitialNotification()- Resolve a cold-start notification tap (used byclient.checkInitialNotification())handleNotificationOpened(data)- Process a notification tap (tracking + callback)handleForegroundMessage(data)- Process a foreground FCM message (inbox sync + notification display)handleBackgroundMessage(data)- Display data-only notifications in background handlerforceSendPendingEvents()- Immediately send any queued engagement eventsdispose()- Stop the batch timer
Notification Adapters
Subpath @releva-ai/sdk-react-native/notifee:
notifeeNotificationAdapter/NotifeeNotificationAdapter- notifee-backed adapter (rich Android notifications, iOS attachments; on iOS it also picks up taps on the notifications iOS draws itself, through@react-native-firebase/messaging)NotificationDisplayService- deprecated alias ofnotifeeNotificationAdapter, kept for one release
Subpath @releva-ai/sdk-react-native/expo:
expoNotificationsAdapter/ExpoNotificationsAdapter- expo-notifications-backed adapter (iOS push requires APNs import enabled on the account)getDevicePushToken()- the device token typed as{ tokenType: 'fcm' | 'apns', token }, ornullgetFcmTokenOrNull()- deprecated in favor ofgetDevicePushToken()extractRelevaPayload(taskBody)- Releva data payload from anexpo-task-managertask body, ornullisRelevaLocalNotification(data)- true for the local notificationdisplayNotification()scheduled itself; exclude it inaddNotificationReceivedListenerto avoid re-displaying it forever
Root export NotificationAdapter is the interface to implement for a custom adapter: initialize({ onNotificationTapped }), displayNotification(data), getInitialNotificationData(), optional dispose().
Types
RelevaNotificationAction
interface RelevaNotificationAction {
target: string | null; // 'screen', 'url', 'inbox', or null
screen: string | null; // App-defined screen name (when target is 'screen').
// Free-form value configured in the Releva dashboard.
url: string | null; // URL to open
parameters: Record<string, any>; // Parsed from navigate_to_parameters JSON
}InboxMessage
interface InboxMessage {
id: string; // Unique delivery ID
title: string; // Message title
design: Record<string, any>; // Unlayer design JSON
read: boolean; // Read status
createdAt: Date; // Delivery timestamp
inboxMessageId: number; // Source message template ID
}Additional Information
For additional information, please visit https://releva.ai or contact [email protected]
