Expo Quickstart
Config plugin for EAS / dev-client builds (not Expo Go).
#Prerequisites
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 prebuildworkflow (not Expo Go) expo-notificationsconfigured 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)
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.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).# The drop-in uses only expo-notifications (present in any Expo push app).
npx expo install expo-notificationsPaste this into lib/pushlane.ts:
// 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.
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();Pushlane.track and Pushlane.registerForPush are no-ops until you have called Pushlane.configure and Pushlane.identify. On logout:// 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.
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');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).#4. Consent (opt-out model)
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.
// 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
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.
| 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.