Retainza

Getting started with the React Native SDK

Install to first delivered push, in order

Overview

@retainza/react-native integrates Retainza into an iOS and Android app built with React Native or Expo: user profiles, events, push notifications, in-app messages and deep-link navigation. It wraps @retainza/sdk-core (plain fetch, no native modules) and adds what only a platform can collect — platform, os_version, app_version, install_date and AppState session tracking — plus the in-app renderer and the Expo config plugin.

The whole integration is ten steps, and the first four are enough to see users and events in the dashboard:

  1. Install the package.
  2. Initialize it with your base URL and public key.
  3. Apply the Expo config plugin (or edit the native projects yourself).
  4. Identify users and track events.
  5. Wire push on Android, then iOS, then the handlers that report delivery.
  6. Mount one component for in-app messages, and one handler for deep links.
This page is the ordered path. The Developer docs page is the full reference — every method, the HTTP API and the other platforms.

Before you start

  • A running Retainza server and its /v1 base URL, e.g. https://api.retainza.com/v1.
  • Your project's public key (pk_…) from API keys. Never put an sk_ key in an app — the SDK throws if you try.
  • React Native 0.70+ and React 18+ (the package's peer range). Expo SDK 50+ if you use Expo.
  • For push: a verified FCM connection in the dashboard — Retainza sends to both Android and iOS through FCM — plus @react-native-firebase/messaging in the app.
Android release builds block plain http://. If your base URL is not HTTPS, everything works in debug and silently fails in release. Put the API behind TLS before you ship.

1. Install

The SDK is not on a public registry. It ships as three tarballs — @retainza/core, @retainza/sdk-core and @retainza/react-native — and you need all three, at the same version.

1Download a release (no repo checkout)

Open SDKs, click Download all, unzip into retainza-sdk/ at your app root, then point the dependencies at the files:

"@retainza/core": "file:retainza-sdk/retainza-core-1.1.1.tgz",
"@retainza/sdk-core": "file:retainza-sdk/retainza-sdk-core-1.1.1.tgz",
"@retainza/react-native": "file:retainza-sdk/retainza-react-native-1.1.1.tgz"

Then npm install and npx expo start -c. The bundle's INSTALL.md carries the exact lines for its version.

2Or build the tarballs from this repo
cd sdk
./scripts/pack-local.sh /path/to/your-app --install

The script builds the three packages, stages the tarballs into <app>/retainza-sdk/, rewrites the three dependencies your app already declares (add them to package.json first) and installs. It exists because packing a pnpm workspace for npm needs two fixes that fail confusingly otherwise: workspace:* left in a packed manifest (npm refuses it), and stale integrity hashes in the app's lockfile (EINTEGRITY).

3Optional peers
npx expo install react-native-safe-area-context   # banners/sheets avoid the notch
npx expo install react-native-webview             # enables Custom HTML in-app messages
npx expo install expo-video                       # video slot in in-app messages
npx expo install @react-native-async-storage/async-storage   # offline queue + install_date
All four are optional and degrade quietly: without safe-area-context a banner can sit under the notch, without webview an HTML message falls back to the native popup, without expo-video the message shows minus the video, and without AsyncStorage the offline queue is memory-only.

2. Initialize

Call this once, as early as possible in app startup — before the first screen renders.

// app/_layout.tsx (or index.js)
import AsyncStorage from "@react-native-async-storage/async-storage";
import Constants from "expo-constants";
import * as Device from "expo-device";                  // optional, see below
import { Retainza } from "@retainza/react-native";

Retainza.init({
  publicKey: process.env.EXPO_PUBLIC_ENGAGE_PUBLIC_KEY, // "pk_..."
  storage: AsyncStorage,                 // offline queue + install_date + sessions
  appVersion: Constants.expoConfig?.version,
  userProvider: () => auth().currentUser?.uid,          // optional: no userId per call
  autoSession: true,                                    // default — sessions off AppState
  device: {                                             // optional, see below
    device_model: Device.modelName ?? undefined,
    device_manufacturer: Device.manufacturer ?? undefined,
  },
  debug: __DEV__,                                       // silent in production
});

// platform and install_date resolve asynchronously. Await this before the very
// first identify if you need them on that call; they ride along on every later one.
await Retainza.ready();

Device model and manufacturer

The SDK collects os_name and os_version from React Native itself, but not the hardware model — that needs a device library, and the SDK deliberately depends on none. Metro resolves every import statically, so a try/catch around expo-device does not make it optional; it makes an app without it fail to bundle. Pass the values instead, from whichever library you already have:

import * as Device from "expo-device";   // or react-native-device-info

device: {
  device_model: Device.modelName ?? undefined,          // "Pixel 8"
  device_manufacturer: Device.manufacturer ?? undefined, // "Google"
}
Omit it entirely and everything still works — the Users list simply shows no hardware model. Anything you pass here wins over what the SDK detected.

You do not need to set a base URL

The SDK ships with Retainza's address built in, so a standard integration never configures one — leave it out and the client talks to the right backend. Override it only if you run Retainza yourself or need to point a build at a staging host. Resolution order, highest priority first: stored runtime override → config.baseUrl → EXPO_PUBLIC_ENGAGE_BASE_URL → the built-in default (https://api.retainza.com/v1). A custom URL must end in /v1.

Setting this is not a privacy measure either way. Whatever address the app talks to is visible in its network traffic and readable from the shipped bundle, so the built-in default reveals nothing that configuring the value would have hidden. The reason to leave it unset is that it is one less thing to get wrong.
Changing an EXPO_PUBLIC_* value and rebuilding is not enough for a release build — Gradle skips re-bundling and Metro's cache still holds the old inlined value, so the app runs with the old URL. See Debug & troubleshooting for the cache-clearing commands.

3. Expo config plugin

withRetainza applies push's native prerequisites to your Expo config so you do not edit Info.plist, entitlements or the Android manifest by hand. It is idempotent — Expo evaluates the config on every build.

// app.config.ts
import { withRetainza } from "@retainza/react-native";

export default ({ config }) =>
  withRetainza(config, {
    publicKey: process.env.EXPO_PUBLIC_ENGAGE_PUBLIC_KEY!,
    apnsEnvironment: "development",   // "production" for App Store / TestFlight builds
  });

What it changes:

  • iOS: aps-environment entitlement, and remote-notification added to UIBackgroundModes.
  • Android: the POST_NOTIFICATIONS permission (required from Android 13).
  • Stashes publicKey (and baseUrl) under extra.engage so they are readable at runtime.
Ship a store build with apnsEnvironment: "development" and every iOS push is rejected by APNs. Drive it from the build profile rather than hard-coding it.
Bare React Native, or an app that does not use Expo config plugins? Make the same three changes in the native projects directly — the plugin is convenience, not a requirement.

4. Identify & track

Identify — on login and on profile changes

Three arguments: the id, your attributes, and the reserved identity Retainza renders with real labels in the dashboard and groups in the segment builder.

await Retainza.get().identify(
  "user-123",
  { plan: "pro", lifetime_value: 248.5 },        // your own attributes
  {                                              // reserved identity
    firstName: "Sam",
    lastName: "Lee",
    email: "sam@acme.com",
    phone: "+919876543210",                      // E.164 recommended
    gender: "female",
    birthdate: "1994-07-21",                     // ISO-8601
    locale: "en-IN",
  },
);
You write firstName; filters, personalization tokens and the CSV export read first_name. The SDK owns that mapping so every integration agrees — without it, one app writes firstName, another first_name, and a segment silently misses half your users.

identify upserts the profile. The SDK's default attributes ride along on every call, so language, country, tz, platform, app_version, install_date and sdk_version never go stale. Anything you pass yourself wins.

tz decides when a user-local campaign sends and country decides who a geo campaign reaches — which is why the SDK collects them rather than leaving it to each app.

Track — user actions

await Retainza.get().track("workout_completed", { minutes: 32 });

The event time is stamped client-side, so an event that sat in the offline queue keeps its real timestamp instead of the flush time.

Update a few attributes

setAttributes is a patch: only the keys you pass are sent and the server merges them, so attributes set elsewhere are left alone.

await Retainza.get().setAttributes({ plan: "pro" });
Use snake_case attribute and event names consistently — they become the operands of every segment filter, and signupSource and signup_source are two different attributes forever.

5. Sessions

A session starts when the app comes to the foreground and ends after 30 minutes in the background. Returning inside that window resumes the same session rather than counting a new one — so a user who taps a push, reads it and comes back is one session, not two.

With autoSession (the default) this is wired to React Native's AppState for you, including the first foreground — which AppState never fires an event for, and which is exactly the session an onboarding campaign cares about.

Retainza.init({ …, autoSession: true, sessionTimeoutMs: 30 * 60_000 });

// Driving it yourself instead (autoSession: false):
AppState.addEventListener("change", (next) => {
  if (next === "active") void Retainza.get().startSession();
  else void Retainza.get().endSession();
});

These four attributes are maintained for you, and every one of them is filterable in Segments:

  • session_count — lifetime sessions. “Opened the app 10+ times” is a segment.
  • first_session_at / last_session_at — epoch millis.
  • session_duration_total — total foreground time in ms.

A session_start event is tracked for genuinely new sessions only, so it is safe to trigger a campaign on it without firing every time the user glances at a notification.

Without a storage adapter the counters live in memory and reset on every relaunch. That undercounts rather than double-counts, which is the safer failure — but pass AsyncStorage and it is simply correct.

6. Push — Android

  1. In the dashboard, add an FCM connection under Connections and paste the Firebase service-account JSON. Retainza verifies it on save and stores only a secret reference.
  2. Put google-services.json for the same Firebase project in your app and install @react-native-firebase/app + @react-native-firebase/messaging.
  3. Request the runtime notification permission (Android 13+) and register the token.
import messaging from "@react-native-firebase/messaging";
import { Retainza } from "@retainza/react-native";
import { PermissionsAndroid, Platform } from "react-native";

export async function registerForPush(userId: string) {
  if (Platform.OS === "android" && Platform.Version >= 33) {
    const granted = await PermissionsAndroid.request(
      PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS,
    );
    if (granted !== PermissionsAndroid.RESULTS.GRANTED) return;
  }

  const token = await messaging().getToken();
  await Retainza.get().registerPush(token, Platform.OS as "ios" | "android", userId);

  // A token can rotate at any time; a stale token is a silently undelivered push.
  messaging().onTokenRefresh((next) =>
    Retainza.get().registerPush(next, Platform.OS as "ios" | "android", userId),
  );
}
registerPush is deliberately never queued offline — replaying an old token later is worse than having no token now. It is safe to call on every launch: an unchanged token is re-sent at most once every 24 hours (pass { force: true } as the fourth argument to skip that).

7. Push — iOS

  1. Retainza sends iOS push through FCM, using the same FCM connection as Android. Upload your APNs key (.p8, key ID + team ID) to Firebase → Project Settings → Cloud Messaging, and add GoogleService-Info.plist to the app. Prefer the .p8 over a .p12 certificate — one key covers all your apps and does not expire yearly.
  2. Make sure the config plugin ran with the right apnsEnvironment, then request permission and register the same way as Android — messaging().requestPermission() replaces the Android permission call. Register the FCM token from messaging().getToken(), not a raw APNs device token.

Images need a Notification Service Extension

Retainza sets mutable-content and apns.fcmOptions.imageUrl when a campaign has an image, but iOS only downloads and attaches the media if your app ships an NSE target. Without one, an image push arrives on iPhone as plain text.

Image URLs must be https. App Transport Security drops http media on iOS, so an http image works on Android and silently vanishes on iPhone — Retainza rejects http image URLs on save for exactly this reason.

Sending to one platform only

A campaign's Send to setting (Message → Push) restricts delivery to iOS, Android or web devices. It filters tokens, not the audience: someone with an iPhone and an Android tablet gets the push on exactly the matching device, and someone with no matching device is skipped rather than counted as a failure.

Prefer this over a segment on the platform attribute for delivery. That attribute records the last device that called identify, so on a multi-device user it will pick the wrong one. A platform segment is still the right tool for a question about who your audience is.

Sound and badge

A campaign's sound maps to aps.sound on iOS and android.notification.sound on Android; a custom sound must be bundled in the app under that name. Badge sets aps.badge on iOS (0 clears it); Android has no badge in FCM, so Retainza maps it to notificationCount, which only some launchers honour.

8. Push handlers & acks

FCM gives no delivery receipt. Push delivered and opened exist in Analytics only because the device reports them. Skip this step and every push campaign's funnel stops at sent.

import messaging from "@react-native-firebase/messaging";
import { parseRetainzaPush, Retainza } from "@retainza/react-native";

const engage = Retainza.get();

// Foreground: report delivery. Do NOT navigate — nobody tapped anything.
messaging().onMessage(async (remote) => {
  const push = parseRetainzaPush(remote?.data);
  if (push) await engage.ackPush(push.msgId, "delivered");
});

// Tapped while backgrounded: report the open, then navigate.
messaging().onNotificationOpenedApp(async (remote) => {
  const push = parseRetainzaPush(remote?.data);
  if (!push) return;                        // not ours — leave it alone
  await engage.ackPush(push.msgId, "opened");
  if (push.route) router.push({ pathname: push.route.screen, params: push.route.kv ?? {} });
});

// Cold start from a tap.
const initial = await messaging().getInitialNotification();
const push = parseRetainzaPush(initial?.data);
if (push) {
  await engage.ackPush(push.msgId, "opened");
  if (push.route) queuePendingRoute(push.route);   // see the warning below
}
Cold-start ordering. getInitialNotification() resolves before your router has mounted, so navigating immediately does nothing at all. Keep a pendingRoute and flush it once navigation is ready.

parseRetainzaPush returns null for anything that is not an Retainza push, so other push SDKs in the same app pass through untouched. Every key Retainza sends is prefixed engage_ for the same reason. Import it from the package root — /ui is a separate entry point so a background handler never pulls React in.

9. In-app messages

Mount one component near your app root. That is the entire in-app integration.

// app/_layout.tsx
import { RetainzaInApp } from "@retainza/react-native/ui";
import { usePathname } from "expo-router";

<RetainzaInApp
  currentScreen={usePathname()}                       // optional "where to show" gate
  blockedScreenPrefixes={["/(auth)", "/onboarding"]}  // never interrupt these
  intervalMs={15000}                                  // default
/>

It owns polling, the queue (one message at a time, the rest stay pending), the shown / clicked / dismissed acks that feed Analytics, and CTA deep links. A blocked screen leaves a message pending rather than consuming it, so nothing is lost.

Layouts are slot lists chosen in the dashboard — image/video, header, title, body and up to three buttons — so a new layout never needs an app release. The same component renders native layouts, Custom HTML messages (in a sandboxed WebView), popups, bottom sheets, banners and full screens.

Overriding the look

<RetainzaInApp presentations={{ banner: MyBanner }} />   // just the banner

<RetainzaInApp render={(msg, { onCta, onDismiss }) => (
  <MyOwnSheet message={msg} onPrimary={onCta} onClose={onDismiss} />
)} />

// Your own analytics, after the SDK's ack:
<RetainzaInApp onCta={(msg) => log("cta", msg.msgId)} onDismiss={(msg) => log("close", msg.msgId)} />

presentations accepts banner, modal, bottomsheet, fullscreen and html; each receives { message, onCta, onDismiss, onButton }.

Self-handled messages

The escape hatch: the Self-handled presentation delivers the payload and draws nothing, so a message can live inside your own UI — a card in a feed, a themed sheet — and still get Retainza's targeting, scheduling and analytics.

Retainza.get().onSelfHandledInApp((msg) => setPromo(msg));

useEffect(() => promo.markShown(), []);             // counts as delivered
<Button onPress={() => promo.markClicked()} />      // acks, then deep-links
<Close  onPress={() => promo.markDismissed()} />
Those acks are yours to send. Retainza cannot see your view, so a self-handled campaign that never calls markShown reports zero deliveries however many people saw it.

12. Logout & offline

Call reset() when a user signs out. Without it the next person to use the device inherits the previous user's identity — their events, and their messages.
export async function signOut() {
  await Retainza.reset();   // BEFORE signOut, while the user id is still known
  await auth().signOut();
}

reset() first unregisters this device's push token from the profile being left (DELETE /v1/token, best-effort), then clears the cached user id, the offline queue, the session counters and startInAppPolling. It is not a data deletion — the server profile is untouched (use DELETE /v1/users/:id for an erasure). install_date and the install id deliberately survive: they describe the device, not the user, so an install-date campaign does not re-fire for the next person who signs in.

The offline queue

track and identify are queued on a network error or a 5xx and replayed oldest-first, carrying their original timestamps. A 4xx is not queued — it would fail forever and block everything behind it. Capacity is 100; the oldest are dropped on overflow. With a storage adapter the queue survives a restart; without one it survives a network blip but not a relaunch.

Attribute reference

Reserved attribute names. The dashboard gives these real labels, formats dates and durations, and groups them on the profile; anything else your app sends is a custom attribute and appears alongside them, equally filterable.

Collected automatically

platformios | android | web
os_name / os_versionFrom React Native
device_model / device_manufacturerOnly if you pass `device` at init
app_versionWhatever you pass as appVersion
sdk_versionThe SDK build — the first thing to check on a bug report
install_dateStamped once into your storage adapter
language / country / tzFrom Intl. tz drives user-local scheduling
session_count, first_session_at, last_session_at, session_duration_totalSession tracking
last_activeBackend — refreshed on every identify and track
push_permissionYou report it; see below

Only from your app

first_name / last_nameidentify(…, { firstName, lastName })
emailAlso stored as a column so the Users list can show it
phoneE.164 recommended
genderFree text — your app's vocabulary
birthdateISO-8601, e.g. 1994-07-21
consent_marketing / consent_analyticssetConsent()

Push permission

The SDK never asks for notification permission — prompting is a product decision with one shot per install, so it belongs to your app. Report what you already know:

const status = await messaging().requestPermission();
await Retainza.get().setPushPermission(
  status === messaging.AuthorizationStatus.AUTHORIZED ? "authorized"
  : status === messaging.AuthorizationStatus.PROVISIONAL ? "provisional"
  : status === messaging.AuthorizationStatus.DENIED ? "denied"
  : "undetermined",
);
Report the denied case too, and report it before any early return. A denied user keeps a perfectly valid token and silently receives nothing — without this attribute the dashboard cannot tell them apart from a reachable one, and “the push never arrived” has no answer.

Integration checklist

Before you call the integration done:

  • Retainza.init runs before the first screen, with an https base URL and a pk_ key.
  • identify() passes firstName/lastName/email/phone, not just the id.
  • setPushPermission() is reported on every branch — including denied.
  • track() covers the events your campaigns will target.
  • registerPush() is called after permission is granted, and again from onTokenRefresh.
  • ackPush() is wired in all three handlers — foreground, background tap, cold start.
  • <RetainzaInApp /> is mounted near the app root, with auth/onboarding screens blocked.
  • onNavigate() is registered and cold-start routes are queued until the router mounts.
  • reset() runs on logout — it clears the session counters too.
  • apnsEnvironment is 'production' in store builds.

Troubleshooting

Start with debug: __DEV__ — it logs every request, poll, ack and swallowed error. Values are never logged, only key names and a prefix of the public key. With debug off the SDK writes nothing to the console.

SymptomUsual cause
No users appear in the dashboardidentify() never ran, or the base URL is wrong. Turn on debug and call testConnection() — it hits GET /health.
Works in debug, dead in releaseA plain http:// base URL (blocked by Android release builds), or a stale bundle still holding the old EXPO_PUBLIC_ value.
Push sends but never arrivesNo token registered (permission denied, or onTokenRefresh not wired), the FCM connection belongs to a different Firebase project than the one in the build, or (iOS) no APNs key uploaded to Firebase.
Push arrives, Analytics shows only 'sent'ackPush() is not wired. FCM gives no delivery receipt — the device is the only source of delivered/opened.
Image push is plain text on iPhoneNo Notification Service Extension in the app, or an http image URL.
In-app message never showsThe current screen matches a blockedScreenPrefixes entry, or a Self-handled campaign has no onSelfHandledInApp handler — the message stays pending rather than being consumed.
CTA does nothingonNavigate() was never registered, or the screen path is not a route in this app.
The dashboard shows the user id but no name or emailidentify() is being called with only an id. Pass the third argument — see Identify & track.
Sessions stay at 0autoSession is off, or no storage adapter was passed so the counters reset every launch.
A user looks reachable but receives nothingpush_permission is denied: a valid token with notifications switched off at the OS level. Check the Reachability column in Users.
An iOS-only campaign reached nobodyNobody in the audience has an iOS token. The campaign's Send-to setting filters devices, not people — the Users list shows the platform split.

Not supported yet

So you do not go looking for them: these exist in some other engagement SDKs but not in Retainza today. Everything above is shipped and testable.

  • Cards / notification inbox — no persistent message feed or notification-centre history API.
  • Location-triggered and device-triggered notifications — no geofence entry/exit or on-device trigger evaluation.
  • Push amplification / OEM push kits (HMS and similar) — delivery is FCM only (iOS via FCM's APNs bridge).

Not a React Native app? Native iOS (Swift) and Android (Kotlin) SDKs and a Flutter SDK (retainza_flutter, Retainza.init(publicKey: …)) are available — see docs/SDK-INTEGRATION-GUIDE-IOS.md, docs/SDK-INTEGRATION-GUIDE-ANDROID.md and docs/SDK-INTEGRATION-GUIDE-FLUTTER.md. Anything else can call the HTTP API directly.