React Native Quickstart
iOS + Android in one JS module — autolinked, Firebase-backed.
#Prerequisites
- React Native 0.73 or later
@react-native-firebase/messagingconfigured (a Firebase project for Android FCM)- For iOS: a physical device running iOS 14+ and a paid Apple Developer account
- Your Pushlane Tenant ID, Ingest URL, and write-key — copy them from Settings → Install SDK
#1. Add the Pushlane client (no npm package)
@pushlane/react-native is not published on npm (a 404). Don't run npm install @pushlane/react-native. Use the zero-dependency drop-in below (recommended), or build the SDK from source in the Pushlane monorepo.lib/pushlane.ts and paste the client below. It reads the device push token from @react-native-firebase/messaging (the same library the SDK uses), so your app must have Firebase messaging installed and configured. Your AI agent can scaffold this exact file via the Pushlane MCP (get_install_instructions).# The drop-in reads the push token from @react-native-firebase/messaging.
# Install it (and its app peer) if your app does not already have it:
npm install @react-native-firebase/app @react-native-firebase/messaging
cd ios && pod install
# Plus a persistent store, so an anonymous user keeps the same id across
# cold starts. Skip this ONLY if every user signs in.
npm install @react-native-async-storage/async-storagePaste this into lib/pushlane.ts:
// Pushlane — drop-in client for React Native. No Pushlane package to install.
// Paste this into your project (e.g. lib/pushlane.ts). Push registration uses
// @react-native-firebase/messaging (already present if your app does push):
// iOS reads the raw APNs token (getAPNSToken), Android the FCM token (getToken).
//
// NOTIFICATION OPENS: automatic on ANDROID only. On iOS the tap is NOT reachable from
// this file (Pushlane sends raw APNs; @react-native-firebase/messaging only surfaces its
// own Firebase notifications) — forward it yourself with
// Pushlane.handleNotificationOpen(response) from your existing notification handler, or
// use the native Pushlane iOS SDK. Until you do, your open rate reads 0%.
//
// ANONYMOUS USERS: identify() is OPTIONAL. If your app has no sign-up (or the user
// has not signed in yet), Pushlane mints ONE stable anonymous id per install and
// persists it, so events AND the push token still reach the server. Persistence uses
// @react-native-async-storage/async-storage when installed, or the { storage } object
// you pass to configure().
import { Platform } from 'react-native';
import messaging from '@react-native-firebase/messaging';
// Best-effort device locale for the event context (sent raw; the server normalises).
function deviceLocale(): string | undefined {
try {
return Intl.DateTimeFormat().resolvedOptions().locale || undefined;
} catch (e) { /* Intl unavailable */ }
return undefined;
}
type PushlaneValue = string | number | boolean | null;
/** Anything with getItem/setItem works: AsyncStorage, react-native-keychain, MMKV. */
interface PushlaneStore {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
}
interface PushlaneConfig {
tenantId: string;
publishableKey: string; // your Pushlane write-key (lpk_live_...)
apiBase?: string;
pushEnvironment?: 'production' | 'sandbox';
/** Where the anonymous id is persisted. Auto-detected when omitted. */
storage?: PushlaneStore;
}
const DEFAULT_API = 'https://loop-ingest.loop-push.workers.dev';
const ANON_KEY = 'com.loop.sdk.anonymousId';
let _cfg: PushlaneConfig | null = null;
let _explicitId: string | null = null;
let _anonReady: Promise<string | null> | null = null;
let _lastToken: string | null = null;
let _lastPlatform: string | null = null;
let _warnedNoStore = false;
function uuidv4(): string {
let s = '';
for (let i = 0; i < 36; i++) {
if (i === 8 || i === 13 || i === 18 || i === 23) { s += '-'; continue; }
if (i === 14) { s += '4'; continue; }
const r = (Math.random() * 16) | 0;
s += (i === 19 ? (r & 0x3) | 0x8 : r).toString(16);
}
return s;
}
function hex(n: number): string { let s = ''; for (let i = 0; i < n; i++) s += ((Math.random() * 16) | 0).toString(16); return s; }
function traceparent(): string { return '00-' + hex(32) + '-' + hex(16) + '-01'; }
function stripSlash(u: string): string { let s = u; while (s.length > 1 && s.charAt(s.length - 1) === '/') s = s.slice(0, -1); return s; }
/** The persistent store for the anonymous id: yours, else whichever is installed. */
function resolveStore(): PushlaneStore | null {
if (_cfg && _cfg.storage) return _cfg.storage;
try {
// Optional dependency (Metro treats a require inside try/catch as optional).
const M = require('@react-native-async-storage/async-storage');
const A = M && (M.default || M);
if (A && A.getItem) {
return { getItem: (k: string) => A.getItem(k), setItem: (k: string, v: string) => A.setItem(k, v) };
}
} catch (e) { /* async-storage not installed */ }
return null;
}
function warnNoStore(): void {
if (_warnedNoStore) return;
_warnedNoStore = true;
// NEVER silent: without a store an anonymous id would be re-minted on every cold
// start, which would fragment flows and inflate your billable user count. So we
// stay inert for anonymous users and tell you exactly how to fix it.
try {
console.warn('[pushlane] no persistent storage found: anonymous users are NOT tracked. Run "npm install @react-native-async-storage/async-storage", or pass { storage } to Pushlane.configure(). Users you identify() explicitly are unaffected.');
} catch {}
}
/** Read the persisted anonymous id, minting + persisting one on first run. */
async function loadAnonId(mintFresh?: boolean): Promise<string | null> {
const store = resolveStore();
if (!store) { warnNoStore(); return null; }
try {
let id = mintFresh ? null : await store.getItem(ANON_KEY);
if (!id) {
id = 'anon_' + uuidv4();
await store.setItem(ANON_KEY, id);
}
return id;
} catch (e) { warnNoStore(); return null; }
}
/** The id every call uses: an explicit identify() wins, else the anonymous id. */
async function currentUserId(): Promise<string | null> {
if (_explicitId) return _explicitId;
if (!_cfg) return null;
if (!_anonReady) _anonReady = loadAnonId();
const anon = await _anonReady;
return _explicitId || anon;
}
async function post(path: string, body: object, extra?: Record<string, string>): Promise<void> {
if (!_cfg) return;
const url = stripSlash(_cfg.apiBase || DEFAULT_API) + path;
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (extra) Object.assign(headers, extra);
if (_cfg.publishableKey) headers['Authorization'] = 'Bearer ' + _cfg.publishableKey;
for (let attempt = 0; attempt <= 4; attempt++) {
try {
const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) });
if (res.ok) return;
if (res.status < 500 && res.status !== 429) {
// non-transient: drop, but SAY SO (a silent 4xx once hid a register
// rejection for weeks). Never throws into the host app.
try { console.warn('[pushlane] ' + path + ' rejected with HTTP ' + res.status + ' (dropped)'); } catch {}
return;
}
} catch (e) { /* retry */ }
if (attempt < 4) await new Promise((r) => setTimeout(r, Math.min(Math.pow(2, attempt) * 500, 30000)));
}
}
/** Re-post the known device token under the CURRENT id (login / logout). The server
* re-attributes a device token to the identity that registered it last. */
function rebindDevice(): void {
const token = _lastToken;
const platform = _lastPlatform;
if (!token || !platform) return;
void (async () => {
const cfg = _cfg;
const uid = await currentUserId();
if (!cfg || !uid) return;
await post('/v1/register', {
tenantId: cfg.tenantId, externalId: uid, deviceToken: token,
pushEnvironment: cfg.pushEnvironment || 'production', platform,
});
})();
}
// -- Notification opens --------------------------------------------------------
// Pushlane stamps message_id / flow_id / node_id into every push it sends. Echoing
// message_id back on an 'opened' event is the ONLY thing that ties an open to the
// exact notification that caused it (open rate = attributed opens / sends). An open
// with no message_id, or under any other event name, is invisible to the metric.
//
// PLATFORM TRUTH, read this before you wire anything:
// Android -- wired automatically below (@react-native-firebase/messaging surfaces
// the tap for you, cold start and warm).
// iOS -- NOT reachable from this file. Pushlane delivers RAW APNs, and RNFB
// only claims the iOS notification-response delegate for its own
// Firebase-marked notifications. There is no pasteable fix: forward the
// tap from wherever your app already handles it (its
// UNUserNotificationCenterDelegate, or a library such as
// notifee / react-native-notifications) by calling
// Pushlane.handleNotificationOpen(response). Passing the raw APNs
// userInfo works too. Or use the native Pushlane iOS SDK, which does
// this for you.
const INTERACTION_KEYS = ['message_id', 'flow_id', 'node_id'];
let _openTrackingReady = false;
let _warnedIosOpens = false;
const _seenResponses: Record<string, boolean> = {};
function interactionProps(data: any): Record<string, PushlaneValue> {
const props: Record<string, PushlaneValue> = {};
if (!data || typeof data !== 'object') return props;
for (const k of INTERACTION_KEYS) {
const v = data[k];
if (typeof v === 'string' && v) props[k] = v;
}
return props;
}
/** The custom payload of an RNFB RemoteMessage, or a raw APNs userInfo. */
function payloadOf(message: any): any {
if (!message || typeof message !== 'object') return {};
if (message.data && typeof message.data === 'object') return message.data;
return message;
}
/** One tap -> exactly one event (getInitialNotification and the listener can both
* deliver the SAME launch message on some RNFB versions). */
function trackOpen(message: any): void {
const data = payloadOf(message);
const props = interactionProps(data);
const key = String((message && message.messageId) || props['message_id'] || '');
if (key) {
if (_seenResponses[key]) return;
_seenResponses[key] = true;
}
Pushlane.track('opened', props);
}
/** Installed from configure() -- the only hook that runs before the first frame,
* which is what a cold-start tap needs. Best-effort; never throws into your app. */
function installOpenTracking(): void {
if (_openTrackingReady) return;
_openTrackingReady = true;
if (Platform.OS !== 'android') {
// Be loud rather than silently reporting a 0% open rate forever.
if (!_warnedIosOpens) {
_warnedIosOpens = true;
try { console.warn('[pushlane] iOS notification opens are NOT auto-tracked in the React Native drop-in (Pushlane sends raw APNs; @react-native-firebase/messaging does not surface those taps). Call Pushlane.handleNotificationOpen(response) from your existing notification handler, or use the native Pushlane iOS SDK. Until then open rate reads 0%.'); } catch {}
}
return;
}
try {
// Cold start: the app was launched BY tapping a notification.
messaging().getInitialNotification()
.then((m: any) => { if (m) trackOpen(m); })
.catch(() => { /* best-effort */ });
// Warm tap: app was backgrounded.
messaging().onNotificationOpenedApp((m: any) => { if (m) trackOpen(m); });
} catch (e) {
try { console.warn('[pushlane] messaging() open listeners unavailable: notification opens are NOT tracked (open rate will read 0).'); } catch {}
}
}
export const Pushlane = {
configure(cfg: PushlaneConfig) { _cfg = cfg; _anonReady = null; installOpenTracking(); },
/** OPTIONAL — anonymous users work without it. Call it when a real user id exists. */
identify(userId: string) {
if (!userId || _explicitId === userId) return;
_explicitId = userId;
rebindDevice(); // the device follows the user
},
/** Logout: forget the account AND mint a fresh anonymous identity for this device. */
reset() {
_explicitId = null;
_anonReady = loadAnonId(true);
rebindDevice();
},
/** Persistent user attributes — feeds {{ name | fallback }} personalisation at send time. */
setAttributes(attributes: Record<string, PushlaneValue>) {
const cfg = _cfg;
if (!cfg) return;
void (async () => {
const uid = await currentUserId();
if (!uid) return;
await post('/v1/attributes', { tenantId: cfg.tenantId, externalId: uid, attributes });
})();
},
track(name: string, properties: Record<string, PushlaneValue> = {}) {
const cfg = _cfg;
if (!cfg) return;
const occurredAt = Date.now(); // stamped NOW, not after the id lookup resolves
void (async () => {
const uid = await currentUserId();
if (!uid) return;
const tp = traceparent();
const context: Record<string, string> = { sdk: 'pushlane-dropin-react-native', sdkVersion: '1.3.0' };
const loc = deviceLocale();
if (loc) context.locale = loc; // sent raw; the server normalises + drives notification language
await post('/v1/events', {
eventId: uuidv4(), tenantId: cfg.tenantId, externalId: uid, name, properties,
occurredAt, context, traceparent: tp,
}, { traceparent: tp });
})();
},
/**
* Report an open Pushlane could not see itself. REQUIRED on iOS (see the note at
* the top of this file); optional on Android, where taps are wired automatically.
* Pass the notification response, the RemoteMessage, or the raw APNs userInfo --
* anything holding message_id. The same notification is only ever counted once.
*/
handleNotificationOpen(responseOrPayload: any) {
if (!responseOrPayload) return;
const n = responseOrPayload.notification || responseOrPayload;
const req = n && n.request;
const src = req ? ((req.content && req.content.data) || (req.trigger && req.trigger.payload) || n) : n;
trackOpen(src);
},
async registerForPush() {
const cfg = _cfg;
if (!cfg) { this.track('push_registration_failed', { reason: 'not_configured' }); return; }
const uid = await currentUserId();
// No id => no persistent store (see warnNoStore) => nothing to attach a device to.
if (!uid) return;
await messaging().requestPermission();
let deviceToken: string | null = null;
if (Platform.OS === 'ios') {
await messaging().registerDeviceForRemoteMessages();
const apns = await messaging().getAPNSToken(); // raw APNs token (what Pushlane delivers to)
deviceToken = apns ? String(apns).toLowerCase() : null;
} else {
deviceToken = await messaging().getToken(); // FCM
}
if (!deviceToken) {
// SAY SO. A silent return here is indistinguishable from "the app never called
// registerForPush() on this path" -- and that ambiguity is exactly what makes a
// low reachable-user count impossible to diagnose. Usually a denied OS prompt.
this.track('push_registration_failed', { reason: 'token_unavailable' });
return;
}
// apns = deliverable today; fcm = registered honestly (Android delivery
// is not live yet — Pushlane's sender is APNs-only for now).
_lastToken = deviceToken;
_lastPlatform = Platform.OS === 'ios' ? 'apns' : 'fcm';
await post('/v1/register', {
tenantId: cfg.tenantId, externalId: uid, deviceToken,
pushEnvironment: cfg.pushEnvironment || 'production',
platform: _lastPlatform,
});
},
setMarketingConsent(optedIn: boolean) {
if (!_cfg) return;
void (async () => {
const uid = await currentUserId();
if (!uid) return;
await post('/v1/consent', { externalId: uid, category: 'marketing', action: optedIn ? 'opt_in' : 'opt_out' });
})();
},
start() { this.track('app_open', {}); },
};
export default Pushlane;
google-services.json. The drop-in calls messaging().getToken() for the FCM token.#2. Configure the client
Import { Pushlane } from the file you just pasted, ideally in index.ts before AppRegistry.registerComponent. That is the whole integration — nothing here requires a logged-in user.
import { Pushlane } from './lib/pushlane';
// Configure once — call before AppRegistry.registerComponent (e.g. in index.ts).
Pushlane.configure({
tenantId: 'YOUR_TENANT_ID',
publishableKey: 'lpk_live_YOUR_KEY', // from Settings → Install SDK
// apiBase defaults to Pushlane's ingest URL — omit unless you self-host.
// storage: AsyncStorage, // optional — see "Anonymous users" below
});
// Emit app_open once per process.
Pushlane.start();
// Request permission and register the device push token
// (raw APNs token on iOS, FCM token on Android).
// This works right now — no account needed.
await Pushlane.registerForPush();Pushlane.configure, and only before it. track, registerForPush and setAttributes all work from the first launch.#Anonymous users (apps without sign-up)
Pushlane.identify is optional. If you never call it, the drop-in mints one stable anonymous id per install (anon_…) and persists it, so a user who never creates an account still gets a device token, still emits events, and still enters flows.
[pushlane] no persistent storage found — it will not invent a new id on every launch, because that would fragment every flow and inflate your billable user count. Install @react-native-async-storage/async-storage and it is detected automatically; users you identify() explicitly are unaffected either way.// The drop-in auto-detects @react-native-async-storage/async-storage. If you
// use something else (MMKV, a custom store), pass it in — any object with
// these two methods works:
import AsyncStorage from '@react-native-async-storage/async-storage';
Pushlane.configure({
tenantId: 'YOUR_TENANT_ID',
publishableKey: 'lpk_live_YOUR_KEY',
storage: AsyncStorage, // or { getItem, setItem } of your own
});The id is stored under com.loop.sdk.anonymousId.
#Identify signed-in users
If your app does have accounts, call Pushlane.identify when the session resolves. It takes over from the anonymous id and re-registers the device token under the real one. Calling it twice with the same id does nothing.
// ONLY if your app has accounts. Call it when auth resolves — the device
// token you already registered moves onto this id automatically.
Pushlane.identify(currentUser.id);
// If you also use RevenueCat, pass the SAME id to Purchases.logIn(). That id
// is what links a purchase to this user.Pushlane.reset(). It forgets the account and mints a fresh anonymous identity for the device.// On logout: detach this device from the user.
Pushlane.reset();#Set user attributes
Call Pushlane.setAttributes to persist user traits that power {{ first_name | friend }} personalisation tokens in your notification copy. It works for anonymous users too.
// Persist traits used in notification personalisation.
// Powers {{ first_name | friend }} tokens in your notification copy.
Pushlane.setAttributes({
first_name: user.firstName, // string
plan: 'growth', // string
trial_days_left: 7, // number
});#3. Track events
Property values are string | number | boolean | null. The backend coerces values against your event catalogue — never coerce client-side.
Pushlane.track('workout_completed', { duration: 25, type: 'run' });
Pushlane.track('paywall_viewed', { placement: 'home', paywall_id: 'fall_sale' });
Pushlane.track('subscription_cancelled', { product_id: 'monthly_pro', reason: 'price' });app_open (when you call Pushlane.start()), opened when a notification is tapped on Android only (see Notification opens below — on iOS that takes one line from you), and push_registration_failed with a reason when registration cannot produce a token. It does not auto-track received or session_started — instrument everything else you care about explicitly with Pushlane.track. Rename any reserved name such as message_sent (see Events & catalogue).#Notification opens (open rate)
Every push Pushlane sends carries message_id, flow_id and node_id in its payload. Open rate is computed by matching opened events back to the send through message_id, so an open logged under a different event name — or without message_id — simply cannot be matched. It is not counted as a miss; it is invisible.
Android is wired for you. Pushlane.configure installs getInitialNotification (the tap that cold-started the app) and onNotificationOpenedApp (the tap that resumed it). The same tap arriving twice is counted once.
iOS taps are NOT reachable from the drop-in — and iOS is the platform Pushlane actually delivers to today. @react-native-firebase/messaging only claims the iOS notification-response delegate for Firebase-marked notifications, and Pushlane sends raw APNs, so those taps never reach this file. Leave it unwired and the only platform currently receiving your pushes reports no opens at all: while nothing attributable has ever arrived, Pushlane refuses to print a fabricated 0% and shows "open tracking isn't reporting yet" instead — and once any other client does report, those unwired iOS sends drag the rate down as if nobody had tapped. The drop-in warns once in the console rather than staying quiet about it.
Two ways to close it: forward the tap yourself with Pushlane.handleNotificationOpen(response) from the handler your app already has, or use the native Pushlane iOS SDK, which does it for you.
// iOS — one line, from wherever your app ALREADY handles a notification tap
// (its UNUserNotificationCenterDelegate, notifee, react-native-notifications…).
// Pass whatever that handler hands you: a response object, a RemoteMessage, or
// the raw APNs userInfo — anything still carrying message_id.
Pushlane.handleNotificationOpen(response);
// e.g. with notifee:
notifee.onForegroundEvent(({ type, detail }) => {
if (type === EventType.PRESS) Pushlane.handleNotificationOpen(detail.notification);
});
// Cold start counts too — forward the notification that launched the app.
// The same notification is only ever counted once, so forwarding twice is safe.handleNotificationOpen exists on every platform, so you can also use it for a tap Android hands you through a path the drop-in does not see. There is no trackOpen method — Pushlane.track('opened', …) works too, but only if you pass message_id through.received is a separate metric and no drop-in emits it: it requires a native iOS Notification Service Extension, which is not something you can paste into a JS file.
#4. Consent (opt-out model)
Pushlane is opt-out by default — a registered device token alone is sufficient for delivery. Only call setMarketingConsent when the user makes an explicit choice in your settings UI.
// Pushlane is opt-out by default — call this only when the user makes an
// explicit choice in your settings UI.
// User turned the marketing toggle OFF:
Pushlane.setMarketingConsent(false);
// User turned it back ON:
Pushlane.setMarketingConsent(true);#Push delivery — what works today
Pushlane.track works over HTTP on both platforms — your events and audiences flow into Pushlane immediately.
registerForPush stores the raw APNs token on iOS and Pushlane delivers over APNs, so end-to-end push delivery is proven on iOS. On Android the drop-in registers an FCM token (platform: "fcm"— the device appears in your Audience); Pushlane's sender is APNs, so Android delivery is not live yet: sends to FCM-only users are suppressed with the logged reason fcm_delivery_unsupported, never silently dropped.
#5. Verify the integration
Run your app on a physical device. In the Pushlane dashboard, go to Settings → Install SDK and watch the live verification panel.
| Check | What it means |
|---|---|
| SDK detected | At least one event has reached the ingest worker |
| Events instrumented | Distinct event names from your app appear in the catalogue |
| Device connected | A device token was registered — you can receive a push |
#Next step
To send real pushes to iOS devices you still need to upload your Apple push key (.p8) to Pushlane.