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:
- Install the package.
- Initialize it with your base URL and public key.
- Apply the Expo config plugin (or edit the native projects yourself).
- Identify users and track events.
- Wire push on Android, then iOS, then the handlers that report delivery.
- Mount one component for in-app messages, and one handler for deep links.
Before you start
- A running Retainza server and its
/v1base URL, e.g.https://api.retainza.com/v1. - Your project's public key (
pk_…) from API keys. Never put ansk_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/messagingin the app.
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.
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.
cd sdk
./scripts/pack-local.sh /path/to/your-app --installThe 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).
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_datesafe-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"
}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.
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-environmententitlement, andremote-notificationadded toUIBackgroundModes. - Android: the
POST_NOTIFICATIONSpermission (required from Android 13). - Stashes
publicKey(andbaseUrl) underextra.engageso they are readable at runtime.
apnsEnvironment: "development" and every iOS push is rejected by APNs. Drive it from the build profile rather than hard-coding it.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",
},
);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" });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.
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
- 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.
- Put
google-services.jsonfor the same Firebase project in your app and install@react-native-firebase/app+@react-native-firebase/messaging. - 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
- 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.plistto the app. Prefer the .p8 over a .p12 certificate — one key covers all your apps and does not expire yearly. - 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 frommessaging().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.
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.
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
}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()} />markShown reports zero deliveries however many people saw it.10. Deep links
A campaign's screen is a path in your app. Retainza carries the path and its parameters; your app resolves them. Register the router once and both push CTAs and in-app CTAs route through it.
import { router } from "expo-router";
Retainza.get().onNavigate((route) =>
router.push({ pathname: route.screen, params: route.kv ?? {} }),
);The campaign builder offers a dropdown of known screens rather than a free-text path. That list is your app's config, in dashboard/src/lib/screenCatalog.ts — add a screen there and it appears for every campaign; a Custom… option always remains.
11. Consent & privacy
What the SDK collects on its own
Everything automatic is device and app context — no permission prompt, no PII, nothing that identifies a person: platform, OS, app version, SDK version, install date, language, country, timezone, session counters. The country comes from the device locale, not from GPS.
Identity is the opposite: first_name, last_name, email, phone, gender, birthdate are only ever what your app passes to identify. The SDK cannot invent them, and a unit test asserts the two lists never overlap.
Gating collection on consent
Retainza.init({ …, requireConsent: true }); // nothing leaves the device yet
// after your consent UI:
await Retainza.get().setConsent({ analytics: true, marketing: false });With requireConsent on, every call is suppressed until analytics is true — enforced at the transport, not per method, so a method added later cannot leak. Suppressed calls are dropped, never queued: replaying them after the fact is the thing consent forbids. identify, track, setAttributes and collection writes resolve quietly; registerPush, ackPush, ackInApp and fetchPendingInApp reject with a ConsentError.
analytics and marketing are separate on purpose. Someone can accept product analytics and refuse promotions; collapsing them into one switch makes that unrepresentable. marketing does not gate transport — it is stored as consent_marketing so campaigns can exclude on it.Opting out entirely
await Retainza.get().optOut(); // stops everything, clears the queue
Retainza.get().optIn(); // resumeoptOut is deliberately not persisted. An opt-out the SDK remembers is one your app cannot see, and your app owns the consent UI — re-assert it on each launch from your own stored preference.Erasure
reset() is an identity clear, not a deletion. For a GDPR erasure use DELETE /v1/users/:id, or the Erase button on the user's profile in the dashboard — both remove the profile, attributes, events, deliveries and tokens.
12. Logout & offline
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
| platform | ios | android | web |
| os_name / os_version | From React Native |
| device_model / device_manufacturer | Only if you pass `device` at init |
| app_version | Whatever you pass as appVersion |
| sdk_version | The SDK build — the first thing to check on a bug report |
| install_date | Stamped once into your storage adapter |
| language / country / tz | From Intl. tz drives user-local scheduling |
| session_count, first_session_at, last_session_at, session_duration_total | Session tracking |
| last_active | Backend — refreshed on every identify and track |
| push_permission | You report it; see below |
Only from your app
| first_name / last_name | identify(…, { firstName, lastName }) |
| Also stored as a column so the Users list can show it | |
| phone | E.164 recommended |
| gender | Free text — your app's vocabulary |
| birthdate | ISO-8601, e.g. 1994-07-21 |
| consent_marketing / consent_analytics | setConsent() |
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",
);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.
| Symptom | Usual cause |
|---|---|
| No users appear in the dashboard | identify() never ran, or the base URL is wrong. Turn on debug and call testConnection() — it hits GET /health. |
| Works in debug, dead in release | A plain http:// base URL (blocked by Android release builds), or a stale bundle still holding the old EXPO_PUBLIC_ value. |
| Push sends but never arrives | No 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 iPhone | No Notification Service Extension in the app, or an http image URL. |
| In-app message never shows | The 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 nothing | onNavigate() was never registered, or the screen path is not a route in this app. |
| The dashboard shows the user id but no name or email | identify() is being called with only an id. Pass the third argument — see Identify & track. |
| Sessions stay at 0 | autoSession is off, or no storage adapter was passed so the counters reset every launch. |
| A user looks reachable but receives nothing | push_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 nobody | Nobody 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.
