npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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: RelevaNotificationAction type 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-native

Peer 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 to onMessage/handleForegroundMessage instead — 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 and getInitialNotification() only cover notifications notifee itself posted. For these tokens, wire react-native-firebase's onNotificationOpenedApp() and getInitialNotification() yourself if you need tap tracking or onNotificationTapped to 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/messaging

2. 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(), and onNotificationTapped fires 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-notifications exposes 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-notifications cannot render a big-picture image on Android, so imageUrl is ignored there (it shows on iOS). The button payload 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-notifications listeners and setNotificationHandler stay as they are; the adapter never calls setNotificationHandler. Filter Releva messages out of your own handlers with isRelevaMessage(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 a useEffect cleanup). 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 minutes

Mark 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=3

The 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 identifier
  • setProfileId(profileId: string, skipMerge?: boolean) - Set user profile identifier. Pass skipMerge: true on logout to prevent merging with the previous profile.
  • setAppVersion(version: string) - Set app version for analytics context
  • setEndpointOverride(url: string | null) - Override API endpoint URL for all calls including inbox. Pass null to clear.
  • setCart(cart: Cart) - Update user's cart
  • setWishlist(products: WishlistProduct[]) - Update user's wishlist
  • clearCartStorage() - Clear stored cart data

Push Notifications

  • enablePushEngagementTracking({ onNotificationTapped, adapter? }) - Enable push notification tracking with required tap callback. The app handles all navigation. adapter may be passed here instead of EngagementTrackingService.setNotificationAdapter().
  • checkInitialNotification() - Handle a notification tap that launched the app from a killed state (tracks engagement and fires onNotificationTapped). Call after enablePushEngagementTracking() once navigation is ready.
  • registerPushToken(type: DeviceType, token: string) - Register FCM token with Releva

Tracking

  • push(request: PushRequest) - Send custom tracking request
  • trackScreenView(options) - Track screen views
  • trackProductView(options) - Track product views
  • trackSearchView(options) - Track search queries
  • trackCheckoutSuccess(options) - Track successful purchases
  • trackCustomEvent(options) - Track custom events
  • trackEvent(eventName: string) - Fire a named event (for NPS triggers)

Inbox

  • initializeInbox() - Initialize the inbox service (call after setProfileId)
  • inbox - Property returning the InboxService singleton

Analytics

  • bannerImpression(banner) - Track banner display
  • bannerAction(banner, action?) - Track banner interaction
  • storyImpression(story) - Track story display
  • storyAction(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 (matches click_action starting with RELEVA_)
  • dispose() - Release resources held by the client

InboxService Methods

  • state - Current InboxState (messages, unreadCount, isLoading, hasMore, nextCursor, lastFetchTime)
  • addListener(listener) / removeListener(listener) - Subscribe to state changes
  • initialize(options) - Initialize with access token, realm, and optional endpoint override
  • setEndpointOverride(url) - Update the API endpoint for inbox calls
  • refresh() - Fetch first page of messages and unread count
  • loadMore() - Fetch next page using cursor pagination
  • markAsRead(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 minutes
  • dispose() - 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 or null
  • static isRelevaMessage(data) - Check if notification data is from Releva
  • checkInitialNotification() - Resolve a cold-start notification tap (used by client.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 handler
  • forceSendPendingEvents() - Immediately send any queued engagement events
  • dispose() - 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 of notifeeNotificationAdapter, 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 }, or null
  • getFcmTokenOrNull() - deprecated in favor of getDevicePushToken()
  • extractRelevaPayload(taskBody) - Releva data payload from an expo-task-manager task body, or null
  • isRelevaLocalNotification(data) - true for the local notification displayNotification() scheduled itself; exclude it in addNotificationReceivedListener to 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]