docs
Documentation menu

Expo Quickstart

Config plugin for EAS / dev-client builds (not Expo Go).

#Prerequisites

Heads up
Expo Go is not supported. Push notifications require native entitlements that Expo Go cannot provide. Use an EAS build or development client (eas build / expo prebuild + npx expo run:ios|android).
  • Expo SDK 50 or later, React Native 0.73 or later
  • EAS CLI or a local expo prebuild workflow (not Expo Go)
  • expo-notifications configured for push (Firebase project for Android FCM)
  • For iOS: a physical device 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
expo-pushlane is not published on npm (a 404). Don't run npm install expo-pushlane. 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 uses only expo-notifications (already in your app for push) plus fetch. Your AI agent can scaffold this exact file for you via the Pushlane MCP (get_install_instructions).
sh
# The drop-in uses expo-notifications (present in any Expo push app)…
npx expo install expo-notifications

# …plus a persistent store, so an anonymous user keeps the same id across
# cold starts. Skip this ONLY if every user signs in.
npx expo install expo-secure-store

Paste this into lib/pushlane.ts:

typescript
// Pushlane — drop-in client for Expo. No Pushlane package to install.
// Paste this into your project (e.g. lib/pushlane.ts) and import { Pushlane } from './pushlane'.
// Uses only expo-notifications (already present in any Expo push app) + global fetch.
//
// NOTIFICATION OPENS: wired automatically from Pushlane.configure() — a tap emits
// 'opened' carrying the message_id from the push payload (that is what powers your open
// rate), and an explicit Notification-Center clear emits 'dismissed'. Call configure()
// at app start, before your first screen, so a cold-start tap is caught. If you already
// have your own tap handler, call Pushlane.handleNotificationOpen(response) from it
// instead of tracking your own event — a differently-named event cannot be matched to a
// send. If you previously hand-rolled one, DELETE it or every tap is logged twice.
//
// 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
// expo-secure-store or @react-native-async-storage/async-storage when either is
// installed, or the { storage } object you pass to configure().
import * as Notifications from 'expo-notifications';

// Best-effort device locale for the event context (sent raw; the server normalises).
function deviceLocale(): string | undefined {
  try {
    // expo-localization is optional (Metro treats a require inside try/catch as optional).
    const Localization = require('expo-localization');
    const tag = Localization.getLocales?.()?.[0]?.languageTag;
    if (tag) return String(tag);
  } catch (e) { /* expo-localization not installed */ }
  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, expo-secure-store, 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 S = require('expo-secure-store');
    if (S && S.getItemAsync) {
      return { getItem: (k: string) => S.getItemAsync(k), setItem: (k: string, v: string) => S.setItemAsync(k, v) };
    }
  } catch (e) { /* expo-secure-store not installed */ }
  try {
    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 "npx expo install expo-secure-store", 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 here once hid a
        // register rejection for weeks (the app looked integrated, no device
        // was ever created). Never throws into the host app.
        try { console.warn('[pushlane] ' + path + ' rejected with HTTP ' + res.status + ' (dropped)'); } catch {}
        return;
      }
    } catch (e) { /* network error: 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). Nothing
// can be inferred server-side: an open with no message_id is invisible to the metric,
// and an event under any other name is invisible too. This is wired for you below --
// do NOT hand-roll a second listener, or you will log two events per tap.
const INTERACTION_KEYS = ['message_id', 'flow_id', 'node_id'];
const IOS_DISMISS_ACTION = 'com.apple.UNNotificationDismissActionIdentifier';
const PUSH_CATEGORY = 'LOOP_DEFAULT'; // wire contract with the Pushlane sender -- never rename
let _openTrackingReady = false;
const _seenResponses: Record<string, boolean> = {};

/**
 * The custom payload, wherever this expo-notifications version put it. On an iOS
 * REMOTE push, content.data is userInfo['body'] ONLY (expo's
 * serializedNotificationData), while our keys ride at the TOP LEVEL of the APNs
 * payload -- so trigger.payload (the raw userInfo) is the one that carries them.
 */
function payloadOf(notification: any): Record<string, any> {
  const req = (notification && notification.request) || {};
  const trigger = req.trigger || {};
  const remote = trigger.remoteMessage || {};
  const candidates = [req.content && req.content.data, trigger.payload, remote.data];
  for (const c of candidates) {
    if (c && typeof c === 'object' && typeof c['message_id'] === 'string' && c['message_id']) return c;
  }
  for (const c of candidates) { if (c && typeof c === 'object') return c; }
  return {};
}

function interactionProps(data: Record<string, any>): Record<string, PushlaneValue> {
  const props: Record<string, PushlaneValue> = {};
  for (const k of INTERACTION_KEYS) {
    const v = data[k];
    if (typeof v === 'string' && v) props[k] = v;
  }
  return props;
}

/** A tap (or an explicit clear) on one notification -> exactly one event. */
function trackResponse(response: any): void {
  if (!response || !response.notification) return;
  const req = response.notification.request || {};
  // getLastNotificationResponseAsync() is NOT consumed by reading it, and on several
  // expo-notifications versions the listener fires for that same launch response --
  // so one tap can arrive twice. Key on the notification plus the action taken.
  const props = interactionProps(payloadOf(response.notification));
  const name = response.actionIdentifier === IOS_DISMISS_ACTION ? 'dismissed' : 'opened';
  const mid = props['message_id'];
  // Key on message_id when we have one, so a manual handleNotificationOpen(payload) for
  // the SAME notification collides with this and the tap is still counted once. Fall
  // back to the notification identifier when the payload carried no message_id.
  const key = typeof mid === 'string' && mid
    ? name + '|' + mid
    : String(req.identifier || '') + '|' + String(response.actionIdentifier || '');
  if (_seenResponses[key]) return;
  _seenResponses[key] = true;
  Pushlane.track(name, props);
}

/** Installed from configure() -- the only hook that runs before the first frame,
 *  which is what a cold-start tap needs. Every step is best-effort and never throws. */
function installOpenTracking(): void {
  if (_openTrackingReady) return;
  _openTrackingReady = true;
  const N = Notifications as any;
  // iOS: register the category so an EXPLICIT Notification-Center clear comes back as
  // a dismiss action. Apple reports explicit clears ONLY -- never a swipe-away, an
  // ignore, or an OS auto-clear -- so dismiss rate always under-counts. Android: no-op.
  try {
    if (typeof N.setNotificationCategoryAsync === 'function') {
      N.setNotificationCategoryAsync(PUSH_CATEGORY, [], { customDismissAction: true })
        .catch(() => { /* never block open tracking on category registration */ });
    }
  } catch (e) { /* older expo-notifications */ }
  // Cold start: the app was launched BY tapping a notification.
  try {
    N.getLastNotificationResponseAsync()
      .then((r: any) => { if (r) trackResponse(r); })
      .catch(() => { /* best-effort */ });
  } catch (e) { /* older expo-notifications */ }
  // Warm tap, foreground tap, explicit clear.
  try {
    N.addNotificationResponseReceivedListener(trackResponse);
  } catch (e) {
    try { console.warn('[pushlane] expo-notifications response listener 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-expo', 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 });
    })();
  },
  /**
   * Escape hatch: report an open Pushlane could not see itself — e.g. your own
   * navigation layer consumes the notification response first. Pass either the
   * expo-notifications response object, or just the push payload (the object that
   * holds message_id). Safe to call alongside the automatic listener: the same
   * notification is only ever counted once.
   */
  handleNotificationOpen(responseOrPayload: any) {
    if (!responseOrPayload) return;
    if (responseOrPayload.notification) { trackResponse(responseOrPayload); return; }
    // A bare payload has no notification identifier, so it is deduped on message_id --
    // otherwise calling this next to the automatic listener would log the tap twice.
    const props = interactionProps(responseOrPayload);
    const mid = props['message_id'];
    if (typeof mid === 'string' && mid) {
      const key = 'opened|' + mid;
      if (_seenResponses[key]) return;
      _seenResponses[key] = true;
    }
    this.track('opened', props);
  },
  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;
    let status = (await Notifications.getPermissionsAsync()).status;
    if (status !== 'granted') {
      status = (await Notifications.requestPermissionsAsync({ ios: { allowAlert: true, allowBadge: true, allowSound: true } })).status;
    }
    if (status !== 'granted') {
      // 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. Costs one event, once.
      this.track('push_registration_failed', { reason: 'permission_denied' });
      return;
    }
    const token = await Notifications.getDevicePushTokenAsync(); // raw APNs (iOS) / FCM (Android)
    // expo-notifications DevicePushToken.type is 'ios' | 'android' (NOT 'apns').
    const isIos = token.type === 'ios';
    const deviceToken = isIos ? String(token.data).toLowerCase() : String(token.data);
    if (!deviceToken) { 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 = isIos ? '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;

#2. Configure the client

Import { Pushlane } from the file you just pasted, then configure, start, and register for push. That is the whole integration — nothing here requires a logged-in user.

typescript
import { Pushlane } from './lib/pushlane';

// Configure once at app startup — App.tsx or 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.
// 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 expo-secure-store (or @react-native-async-storage/async-storage) and it is detected automatically; users you identify() explicitly are unaffected either way.
typescript
// The drop-in auto-detects expo-secure-store, then AsyncStorage. If you use
// something else (MMKV, a custom store), pass it in — any object with these
// two methods works:
Pushlane.configure({
  tenantId: 'YOUR_TENANT_ID',
  publishableKey: 'lpk_live_YOUR_KEY',
  storage: {
    getItem: async (key) => mmkv.getString(key) ?? null,
    setItem: async (key, value) => { mmkv.set(key, value); },
  },
});

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, so the push you were already able to send keeps working — it just reaches a named user now. Calling it twice with the same id does nothing.

typescript
// 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 — the next user of that phone is not the previous one.
typescript
// On logout: detach this device from the user and start a fresh
// anonymous identity for it.
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.

typescript
// 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
});

// null explicitly unsets an attribute:
// Pushlane.setAttributes({ trial_days_left: null });
Note
Re-call whenever a trait changes (e.g. after a subscription upgrade).

#3. Track events

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

typescript
Pushlane.track('purchase_completed', { plan: 'pro', amount: 9.99 });
Pushlane.track('workout_completed', { duration: 42, type: 'run', personal_best: true });

// No-properties variant:
Pushlane.track('app_rated');
Note

What the drop-in emits for you. app_open, when you call Pushlane.start(). And, as of drop-in v1.3.0, opened on every notification tap — plus dismissed when an iOS user explicitly clears the notification. Both are wired automatically from Pushlane.configure(), and both carry the message_id of the notification that caused them. See Notification opens below.

So do not hand-roll your own open event. Pushlane attributes an open to a send by joining on the event name opened and its message_id property. An event under any other name — or an opened without message_id — cannot be matched to a send. It is not counted as a miss; it is simply invisible to the metric. Pushlane does not fabricate a 0% out of that silence either — while nothing attributable has ever arrived, open rate reports "open tracking isn't reporting yet" instead of a number, so taps happening in the real world stay unmeasured rather than mis-measured.

Still not emitted: received (it needs a native iOS Notification Service Extension, which is not something you can paste into a file) and session_started. Instrument every other event you care about explicitly with Pushlane.track, do not track app_open or session_started yourself elsewhere (that would double-count), and rename any reserved name such as message_sent (see Events & catalogue).

#Notification opens

Every push Pushlane sends carries a message_id (plus flow_id and node_id). The drop-in reads them back off the tapped notification and emits opened with those properties — that echo is the only thing that ties an open to the exact notification that caused it, and it is what the open rate on your flow is computed from. You write no code for this.

Heads up
Call Pushlane.configure() at app start, before the first frame. A cold-start tap — the user launching your app by tapping the notification — is recovered when the listeners are installed, and configure() is what installs them. Configure at module scope in App.tsx / index.ts, as in step 2. If you defer it behind a login screen or a loading gate, you keep tracking taps that happen while the app is running, but cold-start opens — the majority of them — go missing.

Where it works. Taps are captured on both platforms by expo-notifications. In practice that means iOS today, because Pushlane's sender is APNs: on Android the device registers an FCM token honestly but no push is delivered yet, so there is nothing to open (see Push delivery).

Note
dismissed always under-counts, by design. The drop-in registers the LOOP_DEFAULT notification category with a custom dismiss action, so iOS reports an explicit clear — the user deliberately clearing the notification from Notification Center. Apple reports nothing else: a swipe-away, an ignored notification, or an OS auto-clear never arrives. Read dismissed as a floor on intentional dismissals, never as "everyone who did not open it".

If your own code handles the response first. Some apps consume the notification response themselves — a deep-link router, for instance. Hand it to Pushlane.handleNotificationOpen() and the tap is still attributed. Pass it the expo-notifications response object — that path shares the automatic listener's de-duplication, so the same notification is only ever counted once even if both see it. It also accepts the bare payload (the object holding message_id), but that form is not de-duplicated: use it only where the automatic listener cannot fire, never in addition to it.

typescript
// You do NOT need this. configure() already listens for taps.
// Use it only when your own code consumes the notification response first —
// e.g. a deep-link router that swallows the event before Pushlane sees it.
import * as Notifications from 'expo-notifications';

Notifications.addNotificationResponseReceivedListener((response) => {
  routeDeepLink(response);                    // your navigation
  Pushlane.handleNotificationOpen(response);  // the same tap is never counted twice
});

// It also accepts the bare push payload — the object carrying message_id —
// but THAT form is not de-duplicated, so only use it where the automatic
// listener cannot fire:
// Pushlane.handleNotificationOpen(response.notification.request.trigger.payload);
Heads up
Upgrading from an older drop-in? Delete your own open listener. If you previously worked around this by calling Pushlane.track('opened', …) from your own notification handler, remove that call when you re-paste the client above — otherwise every tap is logged twice and your open rate reads roughly double. Keeping your handler is fine; just replace your track call with Pushlane.handleNotificationOpen(response), which is de-duplicated.

Pushlane is opt-out by default — registration alone is sufficient for delivery. Call setMarketingConsent only when the user makes an explicit choice in your settings UI.

typescript
// Pushlane is opt-out by default — call this only when the user makes an
// explicit choice in your settings UI.

// User turned marketing notifications OFF:
Pushlane.setMarketingConsent(false);

// User turned them 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. Provisional (silent) push is not available in the drop-in — it requests the standard system permission.

When registration cannot complete, the drop-in emits push_registration_failed with a reason property (permission_denied, not_configured, token_unavailable) rather than returning silently — so a low reachable-user count is diagnosable instead of ambiguous. Expect that event name in your catalogue; it is emitted by the client, not by you. The one case that produces no event is the missing persistent store above: with no id there is nothing to attach an event to either, so that path is the console warning instead.

#5. Verify the integration

Run your EAS / dev-client build on a physical device. In the Pushlane dashboard, open 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) →