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 only expo-notifications (present in any Expo push app).
npx expo install expo-notifications

Paste this into lib/pushlane.ts:

typescript
// Pushlane — zero-dependency drop-in client for Expo. No npm 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.
import * as Notifications from 'expo-notifications';

type PushlaneValue = string | number | boolean | null;
interface PushlaneConfig {
  tenantId: string;
  publishableKey: string; // your Pushlane write-key (lpk_live_...)
  apiBase?: string;
  pushEnvironment?: 'production' | 'sandbox';
}

const DEFAULT_API = 'https://loop-ingest.loop-push.workers.dev';
let _cfg: PushlaneConfig | null = null;
let _userId: string | null = null;

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; }

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) return; // non-transient: drop
    } catch (e) { /* network error: retry */ }
    if (attempt < 4) await new Promise((r) => setTimeout(r, Math.min(Math.pow(2, attempt) * 500, 30000)));
  }
}

export const Pushlane = {
  configure(cfg: PushlaneConfig) { _cfg = cfg; },
  identify(userId: string) { _userId = userId; },
  reset() { _userId = null; },
  track(name: string, properties: Record<string, PushlaneValue> = {}) {
    if (!_cfg || !_userId) return;
    const tp = traceparent();
    void post('/v1/events', {
      eventId: uuidv4(), tenantId: _cfg.tenantId, externalId: _userId, name, properties,
      occurredAt: Date.now(), context: { sdk: 'pushlane-dropin-expo', sdkVersion: '1.0.0' }, traceparent: tp,
    }, { traceparent: tp });
  },
  async registerForPush() {
    if (!_cfg || !_userId) 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') return;
    const token = await Notifications.getDevicePushTokenAsync(); // raw APNs (iOS) / FCM (Android)
    const deviceToken = token.type === 'apns' ? String(token.data).toLowerCase() : String(token.data);
    void post('/v1/register', {
      tenantId: _cfg.tenantId, externalId: _userId, deviceToken,
      pushEnvironment: _cfg.pushEnvironment || 'production',
    });
  },
  setMarketingConsent(optedIn: boolean) {
    if (!_cfg || !_userId) return;
    void post('/v1/consent', { externalId: _userId, 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, identify, start, and register for push — in that order. Identify the user before any track call or registerForPush.

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.
});

// Identify the user after auth resolves (before track / registerForPush).
Pushlane.identify(currentUser.id);

// Emit app_open once per process.
Pushlane.start();

// Request permission and register the device push token.
await Pushlane.registerForPush();
Note
Pushlane.track and Pushlane.registerForPush are no-ops until you have called Pushlane.configure and Pushlane.identify. On logout:
typescript
// On logout: detach this device from the user.
Pushlane.reset();

#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
The drop-in emits app_open only (when you call Pushlane.start()). Unlike the native SDKs it does not auto-track opened, received, or session_started — instrument the events you care about explicitly with Pushlane.track. Do not track app_open/session_started yourself elsewhere (that would double-count), and rename any reserved name such as message_sent (see Events & catalogue).

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; Pushlane's sender is APNs, so Android delivery is not yet end-to-end. Provisional (silent) push is not available in the drop-in — it requests the standard system permission.

#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) →