@masivo/rn
v2.1.0
Published
Masivo SDK for React Native — push notifications and in-app messaging
Readme
@masivo/rn
Official Masivo SDK for React Native — event tracking, push notifications, and in-app messaging.
Important: This SDK requires a CLIENT API key. Do not use SERVER API keys — they will be rejected and the SDK will warn you about exposed server credentials. Create a CLIENT key in the Masivo dashboard.
No native dependencies. This package does not install any native modules. Push token handling is done by your existing push library (Expo Notifications, Firebase, etc.) — you simply pass the token to this SDK.
Installation
npm install @masivo/rnpnpm add @masivo/rnyarn add @masivo/rnQuick Start
import { createRNClient } from "@masivo/rn";
const masivo = createRNClient({
apiKey: "your-client-api-key",
brandId: "your-brand-id" // optional default brand
});
// Track a standard event
await masivo.sendAnalyticsEvent({
type: "ADD_TO_CART",
customer_id: "customer-123",
brand_id: "brand-456",
product: { sku: "SKU-001", amount: 1, value: 29.99 }
});
// Clean up on logout
masivo.destroy();API
createRNClient(config, handlers?)
Creates a Masivo client for React Native that includes event tracking, push notification management, and in-app messaging.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| config.apiKey | string | Yes | Your Masivo CLIENT API key |
| config.brandId | string \| null | No | Default brand ID injected into events that have a platform but no explicit brand_id |
| handlers.onMessagesReady | (messages: InAppMessage[]) => void | No | Called after fetchMessages / fetchPendingMessages returns messages |
| handlers.onMessageShown | (message: InAppMessage) => void | No | Called after an in-app message shown event is tracked |
| handlers.onMessageClicked | (message: InAppMessage) => void | No | Called after an in-app message clicked event is tracked |
| handlers.onMessageDismissed | (message: InAppMessage) => void | No | Called after an in-app message dismissed event is tracked |
Returns a RNClient with:
- All methods from
MasivoClient(sendAnalyticsEvent,flush,destroy,getToken) client.push— push notification managerclient.inApp— in-app messaging manager
Event Tracking
client.sendAnalyticsEvent(event)
Sends an analytics event. Events are buffered and sent in batches.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| type | string | Yes | Event type (e.g. ADD_TO_CART, custom type) |
| customer_id | string | Yes | External customer identifier |
| brand_id | string \| null | Yes | Brand identifier, null if not applicable |
| ...rest | unknown | No | Any additional event data |
await masivo.sendAnalyticsEvent({
type: "ADD_TO_CART",
customer_id: "customer-123",
brand_id: null,
platform: "app"
});client.flush()
Forces an immediate send of all buffered events. Call this before the app goes to background.
// Example: flush on AppState change
import { AppState } from "react-native";
AppState.addEventListener("change", (state) => {
if (state === "background") {
masivo.flush();
}
});Push Notifications
@masivo/rn does not request permissions or receive push tokens itself — that is handled by your push library (Expo Notifications, Firebase, etc.). You obtain the token from your library and pass it to this SDK.
client.push.registerDeviceToken(customerId, deviceToken, deviceInfo)
Registers a device push token with Masivo. Call this when your push library provides a token, or when the user logs in on a device that already has a token.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| customerId | string | Yes | Masivo customer ID |
| deviceToken | string | Yes | Push token from APNs (iOS) or FCM (Android) |
| deviceInfo.platform | "ios" \| "android" | Yes | Device platform |
| deviceInfo.model | string | No | Device model |
| deviceInfo.osVersion | string | No | OS version string, e.g. "17.4" |
Returns Promise<{ success: boolean }>.
// Example with Firebase (react-native-firebase) — use the native FCM token
import messaging from "@react-native-firebase/messaging";
const token = await messaging().getToken();
await masivo.push.registerDeviceToken("customer-123", token, {
platform: "android"
});// Expo: prefer the native device push token for silent in-app wake (FCM/APNs)
import * as Notifications from "expo-notifications";
const tokenResult = await Notifications.getDevicePushTokenAsync();
const token = tokenResult.data;
await masivo.push.registerDeviceToken("customer-123", token, {
platform: "ios",
model: "iPhone",
osVersion: "17.4"
});Token type: Register the native FCM/APNs device token (
messaging().getToken()or ExpogetDevicePushTokenAsync()). Do not use the Expo Push Token (exp.host/...) for Masivo silent push — Masivo sends data-only messages via your Firebase project.
client.push.unregisterDeviceToken(customerId, deviceToken)
Removes a device token from Masivo. Call this on logout or when the user opts out of push notifications.
await masivo.push.unregisterDeviceToken("customer-123", deviceToken);createPushTokenSync(push) — login, refresh, logout
Keeps Masivo in sync when FCM/APNs rotates the device token. No native deps — wire your push library via callbacks.
| Method | Description |
| --- | --- |
| configure(config) | getCustomerId, getDeviceInfo, getToken, optional onTokenRefresh, isPushAllowed, onAppForeground |
| sync() | Alias for enablePush() — register token + refresh listener (call on login) |
| enablePush() | Register when allowed; skips if isPushAllowed returns false |
| disablePush() | Stop refresh listener + unregister last token (opt-out / logout) |
| reconcilePushState() | Sync Masivo token with OS permission ("enabled", "disabled", or "skipped") |
| registerCurrentToken() | Register token from getToken() once |
| registerToken(token) | Register a specific token string |
| unregisterLastToken() | Unregister last registered token only |
| startRefreshListener() | Subscribe to token rotation without re-registering |
| stop() | Unsubscribe refresh listener only |
// lib/masivo.ts
import { AppState, Platform } from "react-native";
import messaging from "@react-native-firebase/messaging";
import { createRNClient, createPushBridge, createPushTokenSync } from "@masivo/rn";
export const masivo = createRNClient({
apiKey: process.env.EXPO_PUBLIC_MASIVO_CLIENT_KEY!,
brandId: process.env.EXPO_PUBLIC_MASIVO_BRAND_ID
});
export const pushBridge = createPushBridge(masivo.inApp);
export const pushTokenSync = createPushTokenSync(masivo.push);
pushBridge.configure({
getCustomerId: () => session.customerId,
getBrandId: () => process.env.EXPO_PUBLIC_MASIVO_BRAND_ID ?? null
});
pushTokenSync.configure({
getCustomerId: () => session.customerId,
getDeviceInfo: () => ({
platform: Platform.OS as "ios" | "android",
osVersion: String(Platform.Version)
}),
getToken: async () => messaging().getToken(),
onTokenRefresh: handler => messaging().onTokenRefresh(handler),
isPushAllowed: async () => {
const status = await messaging().hasPermission();
const authorized = status === messaging.AuthorizationStatus.AUTHORIZED;
const provisional = status === messaging.AuthorizationStatus.PROVISIONAL;
return authorized || provisional;
},
onAppForeground: handler => {
const sub = AppState.addEventListener("change", state => {
if (state === "active") handler();
});
return () => sub.remove();
}
});
// On login
await pushTokenSync.sync();
// On logout
await pushTokenSync.disablePush();
masivo.destroy();Expo equivalent — swap getToken / onTokenRefresh for getDevicePushTokenAsync() and Expo's push token listener if your setup exposes one; the pattern is the same.
client.push.trackNotificationOpened(event)
Tracks when a user opens a push notification. Delegates to sendAnalyticsEvent under the hood.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| type | string | Yes | Event type, e.g. PUSH_OPENED |
| customer_id | string | Yes | Customer ID |
| brand_id | string \| null | Yes | Brand ID |
| notification_data | Record<string, unknown> | No | Payload from the notification |
// Example: track notification open from background handler
await masivo.push.trackNotificationOpened({
type: "PUSH_OPENED",
customer_id: "customer-123",
brand_id: "brand-456",
notification_data: {
campaign_id: "camp-001",
message_id: "msg-xyz"
}
});In-App Messaging
Masivo in-app works like Braze: you integrate the SDK only. Fetch, display rules, and impression logging are handled internally — you do not call REST endpoints from your app.
Default UI (recommended)
Wrap your app after login. The SDK fetches on session start, renders messages by type, and logs impressions/clicks/dismissals automatically.
import { createRNClient, MasivoInAppProvider } from "@masivo/rn";
const masivo = createRNClient({
apiKey: process.env.EXPO_PUBLIC_MASIVO_CLIENT_KEY!,
brandId: process.env.EXPO_PUBLIC_MASIVO_BRAND_ID,
baseUrl: "http://10.0.2.2:3000"
});
export function AppShell({ customerId }: { customerId: string }) {
return (
<MasivoInAppProvider
client={masivo}
customerId={customerId}
brandId={process.env.EXPO_PUBLIC_MASIVO_BRAND_ID}
>
<YourApp />
</MasivoInAppProvider>
);
}Supported default layouts: modal, fullscreen, slideup, banner, content_card.
Custom UI (headless)
Set customUI and render your own components. Use useMasivoInApp() for the queue and Braze-style logging methods:
import { MasivoInAppProvider, useMasivoInApp } from "@masivo/rn";
const CustomInApp = () => {
const { currentMessage, logImpressionForMessage, clickMessage, dismissMessage } =
useMasivoInApp();
if (!currentMessage) return null;
// Render your UI, then:
// await logImpressionForMessage(currentMessage);
// await clickMessage(currentMessage, button);
// await dismissMessage(currentMessage);
return null;
};
<MasivoInAppProvider client={masivo} customerId={id} customUI>
<YourApp />
<CustomInApp />
</MasivoInAppProvider>SDK methods (no HTTP required)
| Method | Description |
| --- | --- |
| client.inApp.fetchPendingMessages(customerId, brandId?) | Fetch all pending eligible messages (no trigger filter) |
| client.inApp.fetchMessages(params) | Fetch by optional trigger, brand, or type |
| client.inApp.logImpression(message, baseEvent) | Message was displayed |
| client.inApp.logClick(message, baseEvent) | User tapped CTA |
| client.inApp.logDismiss(message, baseEvent) | User closed the message |
| client.inApp.subscribe(handler) | Listen after each fetch |
| client.inApp.handlePushPayload(data, customerId, brandId?) | Handle silent push data payload |
logImpression, logClick, and logDismiss are the primary API. handleMessageShown, handleMessageClicked, and handleMessageDismissed remain as aliases.
Message types
| Type | Use case |
| --- | --- |
| modal | Promos, onboarding |
| fullscreen | Full-screen campaigns |
| slideup | Bottom sheet style |
| banner | Top/bottom banners |
| content_card | Feed-style cards |
Optional offline cache
import AsyncStorage from "@react-native-async-storage/async-storage";
import { setAsyncStorage } from "@masivo/rn";
setAsyncStorage(AsyncStorage);Silent push wake (near realtime)
When Masivo creates an in-app message, it can send a silent data push to the customer device. Forward that payload to the SDK — no REST calls from your app.
Use createPushBridge to wire FCM or Expo without extra Masivo packages:
// lib/masivo.ts
import { createRNClient, createPushBridge } from "@masivo/rn";
export const masivo = createRNClient({
apiKey: process.env.EXPO_PUBLIC_MASIVO_CLIENT_KEY!,
brandId: process.env.EXPO_PUBLIC_MASIVO_BRAND_ID
});
export const pushBridge = createPushBridge(masivo.inApp);
pushBridge.configure({
getCustomerId: () => session.customerId,
getBrandId: () => process.env.EXPO_PUBLIC_MASIVO_BRAND_ID ?? null
});Payload keys (set by Masivo server):
| Key | Value |
| --- | --- |
| masivo_type | INAPP_FETCH |
| masivo_trigger | optional event trigger (omit for pending fetch) |
| masivo_customer_id | customer id |
| masivo_brand_id | optional brand id |
Requirements:
- Device token registered via
masivo.push.registerDeviceToken(native FCM/APNs token) - Firebase configured in Masivo dashboard (same as push)
- Push permission / consent for the customer
With MasivoInAppProvider, fetched messages appear automatically after the
silent push handler runs.
Push integration (FCM & Expo)
@masivo/rn has no native push dependencies. You connect your existing push
library to pushBridge.handle(data).
React Native Firebase
Foreground (onMessage) and background (setBackgroundMessageHandler in
index.js):
import messaging from "@react-native-firebase/messaging";
import { pushBridge } from "@/lib/masivo";
messaging().onMessage(async remoteMessage => {
const data = remoteMessage.data ?? {};
await pushBridge.handle(data);
});
// index.js (outside React tree)
messaging().setBackgroundMessageHandler(async remoteMessage => {
const data = remoteMessage.data ?? {};
await pushBridge.handle(data);
});Expo Notifications
Foreground listener and headless background task:
import * as Notifications from "expo-notifications";
import * as TaskManager from "expo-task-manager";
import { pushBridge } from "@/lib/masivo";
const INAPP_TASK = "MASIVO_INAPP_PUSH";
Notifications.addNotificationReceivedListener(async notification => {
const data = notification.request.content.data as Record<string, unknown>;
await pushBridge.handle(data);
});
TaskManager.defineTask(INAPP_TASK, async ({ data, error }) => {
if (error || !data) return;
const payload = (data as { body?: Record<string, unknown> }).body ?? data;
await pushBridge.handle(payload as Record<string, unknown>);
});
await Notifications.registerTaskAsync(INAPP_TASK);On iOS, enable the remote-notification background mode for silent delivery.
See Expo headless notifications.
Foreground vs background
- Background / killed (limited on iOS):
pushBridge.handlefetches and caches messages; UI updates when the app opens viaMasivoInAppProvider. - Foreground: same handler; provider subscribers refresh the queue.
iOS note: Silent data pushes are not guaranteed when the app is force-quit.
Push opt-out & OS permissions
Push delivery involves three layers your app must coordinate. The SDK handles device token registration only; consent and OS permission stay in your app.
| Layer | Responsibility | SDK |
| --- | --- | --- |
| In-app preference | Toggle in settings UI | disablePush() / enablePush() |
| OS permission | FCM requestPermission / Expo permissions | Wire via isPushAllowed |
| Masivo consent | purposes.push_notifications on the customer | Your backend/BFF (not in SDK) |
See FCM Token Management and the storefront consent endpoint (PATCH /customers/{id}/consent) for consent updates.
What each layer blocks
| Scenario | Silent push | In-app on open |
| --- | --- | --- |
| Token not registered | No | Yes |
| push_notifications: false in Masivo | No | Yes |
| OS permission denied | No | Yes |
In-app messages loaded via fetchPendingMessages() when the app opens do not
depend on push. Only realtime delivery via silent push requires a valid token and
consent.
Configure permission + foreground reconciliation
import { AppState } from "react-native";
import messaging from "@react-native-firebase/messaging";
import AsyncStorage from "@react-native-async-storage/async-storage";
const PUSH_PREF_KEY = "pushEnabled";
pushTokenSync.configure({
getCustomerId: () => session.customerId,
getDeviceInfo: () => ({
platform: Platform.OS as "ios" | "android",
osVersion: String(Platform.Version)
}),
getToken: async () => messaging().getToken(),
onTokenRefresh: handler => messaging().onTokenRefresh(handler),
isPushAllowed: async () => {
const pref = await AsyncStorage.getItem(PUSH_PREF_KEY);
const inAppEnabled = pref !== "false";
const status = await messaging().hasPermission();
const authorized = status === messaging.AuthorizationStatus.AUTHORIZED;
const provisional = status === messaging.AuthorizationStatus.PROVISIONAL;
const osGranted = authorized || provisional;
return inAppEnabled && osGranted;
},
onAppForeground: handler => {
const sub = AppState.addEventListener("change", state => {
if (state === "active") handler();
});
return () => sub.remove();
}
});When onAppForeground is set, configure automatically calls
reconcilePushState() each time the app returns to the foreground (e.g. after
changing notification settings in the OS).
Toggle OFF (in-app opt-out)
await pushTokenSync.disablePush();
await AsyncStorage.setItem(PUSH_PREF_KEY, "false");
// Update Masivo consent (purposes.push_notifications: false) via your BFFToggle ON
const status = await messaging().requestPermission();
const authorized = status === messaging.AuthorizationStatus.AUTHORIZED;
const provisional = status === messaging.AuthorizationStatus.PROVISIONAL;
const granted = authorized || provisional;
if (!granted) return;
await AsyncStorage.setItem(PUSH_PREF_KEY, "true");
await pushTokenSync.enablePush();
// Update Masivo consent (purposes.push_notifications: true) via your BFFLogout
disablePush() replaces stop() + unregisterLastToken():
await pushTokenSync.disablePush();
masivo.destroy();Breaking changes (v2)
| Removed | Replacement |
| --- | --- |
| client.inApp.onSessionStart() | client.inApp.fetchPendingMessages() |
| SESSION_START_TRIGGER export | Not needed — fetch pending messages without a trigger |
| Default masivo_trigger: SESSION_START in server silent push | Server omits masivo_trigger; SDK fetches all pending messages |
Full Setup Example (Expo)
// lib/masivo.ts
import { AppState, Platform } from "react-native";
import messaging from "@react-native-firebase/messaging";
import { createRNClient, createPushBridge, createPushTokenSync } from "@masivo/rn";
import { MasivoInAppProvider } from "@masivo/rn";
export const masivo = createRNClient({
apiKey: process.env.EXPO_PUBLIC_MASIVO_CLIENT_KEY!,
brandId: process.env.EXPO_PUBLIC_MASIVO_BRAND_ID,
baseUrl: process.env.EXPO_PUBLIC_MASIVO_API_URL
});
export const pushBridge = createPushBridge(masivo.inApp);
export const pushTokenSync = createPushTokenSync(masivo.push);
pushTokenSync.configure({
getCustomerId: () => session.customerId,
getDeviceInfo: () => ({
platform: Platform.OS as "ios" | "android",
osVersion: String(Platform.Version)
}),
getToken: async () => messaging().getToken(),
onTokenRefresh: handler => messaging().onTokenRefresh(handler),
isPushAllowed: async () => {
const status = await messaging().hasPermission();
const authorized = status === messaging.AuthorizationStatus.AUTHORIZED;
const provisional = status === messaging.AuthorizationStatus.PROVISIONAL;
return authorized || provisional;
},
onAppForeground: handler => {
const sub = AppState.addEventListener("change", state => {
if (state === "active") handler();
});
return () => sub.remove();
}
});
pushBridge.configure({
getCustomerId: () => session.customerId,
getBrandId: () => process.env.EXPO_PUBLIC_MASIVO_BRAND_ID ?? null
});// app/_layout.tsx (after login)
import { masivo, MasivoInAppProvider } from "@/lib/masivo";
export function RootLayout({ customerId }: { customerId: string }) {
return (
<MasivoInAppProvider client={masivo} customerId={customerId}>
<Stack />
</MasivoInAppProvider>
);
}// index.js — background silent push (FCM)
import messaging from "@react-native-firebase/messaging";
import { pushBridge } from "@/lib/masivo";
messaging().setBackgroundMessageHandler(async remoteMessage => {
const data = remoteMessage.data ?? {};
await pushBridge.handle(data);
});// On login / logout
import { masivo, pushTokenSync } from "@/lib/masivo";
async function onLogin() {
await pushTokenSync.sync();
}
async function onLogout() {
await pushTokenSync.disablePush();
masivo.destroy();
}Error Handling
All error classes are re-exported from @masivo/core:
import { MasivoError, MasivoServerKeyError, MasivoAuthError, MasivoNetworkError } from "@masivo/rn";
try {
await masivo.sendAnalyticsEvent({ ... });
} catch (error) {
if (error instanceof MasivoServerKeyError) {
console.error(error.message);
} else if (error instanceof MasivoNetworkError) {
console.error("Network error:", error.message);
} else if (error instanceof MasivoAuthError) {
console.error("Auth error:", error.message, error.details);
} else if (error instanceof MasivoError) {
console.error("API error:", error.status, error.message);
}
}| Error class | When | Properties |
| --- | --- | --- |
| MasivoServerKeyError | SERVER API key used instead of CLIENT | message |
| MasivoNetworkError | Fetch fails (no connectivity, DNS, etc.) | message |
| MasivoAuthError | Authorization fails (invalid/expired key) | message, status, details |
| MasivoError | API returns an error response | message, status, details |
Allowed Event Types
This SDK uses CLIENT API keys, which can only emit:
- Tracking events —
ADD_TO_CART,EMPTY_CART, and other tracking types. - Custom event types — Any custom event type you have created in the Masivo platform.
Default event types like PURCHASE, ABANDONED_CART, BIRTHDAY, REGISTRATION, and TIER_ADJUSTMENT are not allowed from the client SDK. These must be sent from your backend using a SERVER API key directly via the Masivo REST API.
Token Management
- On the first event or device registration, the SDK exchanges your CLIENT API key for a bearer token.
- The token is proactively refreshed every 5 minutes in the background.
- If a request returns
401, the SDK invalidates the token and retries once with a fresh one. destroy()cancels the refresh interval — always call it on logout.
Compatibility
- React Native >= 0.71 (with built-in
fetch) - Expo (managed and bare workflow)
- No native modules required
- No Objective-C, Swift, Java, or Kotlin code
Links
- Masivo Documentation
- Masivo Dashboard
@masivo/core— underlying core package@masivo/client— web browser SDK
License
MIT
