docs
Documentation menu

React Native Quickstart

iOS + Android in one JS module — autolinked, Firebase-backed.

#Prerequisites

  • React Native 0.73 or later
  • @react-native-firebase/messaging configured (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)

Heads up
@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.
Note
No package to install — paste one file. Create 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).
sh
# 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-storage

Paste this into lib/pushlane.ts:

typescript
// 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;
Note
Android — Firebase setup: follow the React Native Firebase messaging guide to add 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.

tsx
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();
Note
Everything is a no-op before 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.

Heads up
The persistent store is load-bearing. Without one, the drop-in refuses to track anonymous users and logs [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.
tsx
// 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.

tsx
// 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.
Note
On logout, call Pushlane.reset(). It forgets the account and mints a fresh anonymous identity for the device.
tsx
// 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.

tsx
// 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
});
Note
Re-call whenever a trait changes.

#3. Track events

Property values are string | number | boolean | null. The backend coerces values against your event catalogue — never coerce client-side.

tsx
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' });
Note
What the drop-in emits on its own: 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.

Heads up

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.

tsx
// 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.
Note
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.

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.

tsx
// 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

Note

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.

CheckWhat it means
SDK detectedAt least one event has reached the ingest worker
Events instrumentedDistinct event names from your app appear in the catalogue
Device connectedA 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.

APNs setup (.p8) →