npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

xtremepush-expo-plugin

v1.3.3

Published

XtremePush Expo Config Plugin

Readme

XtremePush Expo Plugin

Expo config plugin that integrates the XtremePush SDK functionality with full React Native module support for both iOS and Android platforms.

  • Latest version: 1.3.3
  • Supported platforms: iOS 15.0+, Android 5.0+ (API 21)
  • Supported Expo SDK: 51 through 55 (53+ recommended)
  • Distribution: managed Expo projects via EAS, or local expo run:ios / expo run:android builds. Does not work in Expo Go.

Table of contents


What's new in 1.3.3

1.3.3 fixes the cold-start deeplink drop under encrypted push, adds two user-update APIs, and makes the plugin resolve cleanly on Expo SDK 54 / 55.

| Change | Impact | |---|---| | Cold-start deeplink fix (encrypted push) | On a notification tap that launches the app from a killed state, the SDK's decrypted deeplink / message-response is now buffered natively and replayed once your JS listener subscribes — so onDeeplinkReceived returns a readable deeplink instead of consumers falling back to getInitialNotification()'s encrypted transport copy. Opt-in via deeplinkCallback / messageResponseCallback. See Receiving deeplinks with encrypted push. | | updateUser(payload) | New JS API mapping to the SDK's userUpdate operation, with integer-fidelity JSON transport. See Updating a user. | | Channel subscription preferences | updatePushSubscriptionPreferences / updateEmailSubscriptionPreferences / updateSmsSubscriptionPreferences for per-topic consent on a base channel. See Channel subscription preferences. | | Expo SDK 54 / 55 compatibility | @expo/config-plugins is now a peer dependency (>=8.0.0) instead of a pinned dependency, so the plugin binds to whatever config-plugins the host Expo project ships. Node engine floor raised to >=20.19.4 (an SDK 54 requirement). |

No breaking changes. The deeplink fix, updateUser, and the subscription-preference APIs are all additive; the config-plugins change is a resolution fix with no behavioural impact on existing SDK ≤53 projects.


Requirements

System

  • Node.js 20.19.4 or later (required by Expo SDK 54+; older SDKs work on Node 18, now end-of-life)
  • Expo SDK 51 or later (53+ recommended; SDK 54 and 55 supported)
  • React Native — whatever version your Expo SDK ships (SDK 51 → RN 0.74, SDK 55 → RN 0.79+). You don't pin this yourself; Expo does.
  • EAS CLI 18 or later (only if building with EAS)

The plugin uses @expo/config-plugins as a peer dependency — it resolves to the version your Expo SDK already provides (8.x on SDK 51 through 55.x on SDK 55). No action is required on your part.

iOS

  • Deployment target 15.0 or later (the plugin sets this automatically for the NSE)
  • Xcode — the version your Expo SDK requires (Xcode 15+ for SDK 51–53, Xcode 16+ for SDK 54–55)
  • CocoaPods latest
  • An Apple Developer Program membership (paid, individual or organisation)
  • For rich media or delivery receipts, the ability to register an App Group identifier in Apple Developer Portal

Android

  • minSdkVersion 21 (Android 5.0)
  • targetSdkVersion 34 (Android 14)
  • Gradle 8.0 or later
  • Google Play Services for FCM
  • A Firebase project with an Android app entry whose package name matches android.package

Installation

# npm
npm install xtremepush-expo-plugin

# yarn
yarn add xtremepush-expo-plugin

# pnpm
pnpm add xtremepush-expo-plugin

The plugin pulls in the native iOS and Android XtremePush SDKs at build time via CocoaPods and Gradle respectively. There's no separate native install step.


Quick start

This config enables basic push delivery:

// app.config.js
export default {
  expo: {
    name: 'YourApp',
    slug: 'your-app',
    ios: {
      bundleIdentifier: 'com.yourcompany.yourapp',
    },
    android: {
      package: 'com.yourcompany.yourapp',
      googleServicesFile: './google-services.json',
    },
    plugins: [
      ['xtremepush-expo-plugin', {
        applicationKey: 'YOUR_XTREMEPUSH_APP_KEY',
        iosAppKey: 'YOUR_IOS_APP_KEY',
        androidAppKey: 'YOUR_ANDROID_APP_KEY',
        googleSenderId: 'YOUR_FCM_SENDER_ID',
      }],
    ],
  },
};

Then:

npx expo prebuild --clean

That gets you basic push delivery. To enable rich media and delivery receipts on iOS — recommended for production — see Quick start with rich media + delivery receipts below.

Quick start with rich media + delivery receipts

// app.config.js
export default {
  expo: {
    name: 'YourApp',
    slug: 'your-app',
    ios: {
      bundleIdentifier: 'com.yourcompany.yourapp',
    },
    android: {
      package: 'com.yourcompany.yourapp',
      googleServicesFile: './google-services.json',
    },
    plugins: [
      ['xtremepush-expo-plugin', {
        applicationKey: 'YOUR_XTREMEPUSH_APP_KEY',
        googleSenderId: 'YOUR_FCM_SENDER_ID',

        // iOS rich media + delivery receipts
        enableRichMedia: true,
        enableDeliveryReceipts: true,
        iosAppGroupIdentifier: 'group.com.yourcompany.yourapp.xtremepush.suit',
        devTeam: 'ABCDE12345',                      // your Apple Team ID
      }],
    ],
    extra: {
      eas: {
        projectId: 'your-eas-project-id',           // run `eas init` to populate
      },
    },
  },
};

The plugin auto-injects the extra.eas.build.experimental.ios.appExtensions block at prebuild. Do not write it by hand. See Apple Developer Portal for the one-time Apple-side setup.


Plugin configuration reference

Every option goes inside the plugin's options object in app.config.js / app.json:

plugins: [
  ['xtremepush-expo-plugin', { /* options here */ }],
]

Required

| Option | Type | Description | |---|---|---| | applicationKey | string | Your XtremePush application key (from the dashboard). | | googleSenderId | string | Firebase numeric Sender ID. Required for Android push. |

Required when an NSE is created

(i.e. when enableRichMedia or enableDeliveryReceipts is true.)

| Option | Type | Description | |---|---|---| | devTeam | string | Apple Developer Team ID (e.g. 'ABCDE12345'). The plugin throws XP_E003 at prebuild if missing. Find it at developer.apple.com → Membership or in the output of eas credentials -p ios. |

Push permission control

| Option | Type | Default | Description | |---|---|---|---| | enablePushPermissions | boolean | true | Auto-request the OS push prompt at launch. Set to false to defer until you call requestNotificationPermissions() from JS. Also controls whether POST_NOTIFICATIONS is added to the Android manifest. |

Feature flags

| Option | Type | Default | Description | |---|---|---|---| | enableRichMedia | boolean | false | iOS rich media (image/video). Creates the Notification Service Extension. | | enableDeliveryReceipts | boolean | false | Push delivery receipts. iOS: also creates the NSE. Android: requires SDK ≥ 7.9.0. | | enableEncryptedPush | boolean | false | iOS encrypted push payloads. Requires uploading the public key in the dashboard. | | enableEncryptedMessages | boolean | false | Android encrypted messages. Requires Android SDK ≥ 8.1.0 plus a dashboard-side key. | | enableInAppMessaging | boolean | true | In-app message rendering. | | enableLocationServices | boolean | true | Adds location permissions on Android (ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION, ACCESS_BACKGROUND_LOCATION) and the iOS usage descriptions. Set to false if you don't use location features — recommended to avoid Play / App Store review scrutiny. | | enableStartSession | boolean | true | Android setEnableStartSession(true) — app-launch session tracking. | | enableInboxBadge | boolean | true | Android setInboxBadgeEnabled(true) — inbox unread counter. | | enableLoyalty | boolean | false | Loyalty Widget integration. Requires loyaltyEndpoint and loyaltyTokenRefreshCallback (XP_E004 / XP_E005). See Loyalty Widget. | | enableDebugLogs | boolean | false | Verbose SDK logging on both platforms. |

iOS

| Option | Type | Default | Description | |---|---|---|---| | iosAppKey | string | applicationKey | iOS-specific application key override. | | iosAppGroupIdentifier | string | auto-derived | App Group shared between the main app and NSE. Must end with .xtremepush.suit when enableDeliveryReceipts is true (XP_E001 otherwise). Auto-derived as group.<bundleId>.xtremepush.suit for delivery receipts, or group.<bundleId>.xtremepush for rich-media-only. | | iosAppGroup | string | unset | Legacy alias for iosAppGroupIdentifier, used when only rich media is enabled. Prefer iosAppGroupIdentifier. | | nseTargetName | string | 'XtremePushNotificationServiceExtension' | Override the NSE Xcode target name. Almost never needed. | | iosSdkVersion | string | '6.1' | Pin the Xtremepush-iOS-SDK CocoaPods version (e.g. '6.1' produces pod 'Xtremepush-iOS-SDK', '~> 6.1'). | | apsEnvironment | 'development' | 'production' | auto for EAS | (1.2.9+) Auto-derived to 'production' when an NSE is created on an EAS project. Override only if you build with an EAS Development-type provisioning profile and need sandbox tokens. |

Android

| Option | Type | Default | Description | |---|---|---|---| | androidAppKey | string | applicationKey | Android-specific application key override. | | androidDependency | string | 'ie.imobile.extremepush:XtremePush_lib:9.8.2' | Full Gradle dependency string. Use this for Huawei HMS builds or custom artifacts. Builds against SDK >= 9.7.0 (XP_W008 below that); loyalty in-widget navigation needs >= 9.8.0 (XP_W009). | | androidDependencyVersion | string | '9.8.2' | Override just the version of the default dependency. Builds against >= 9.7.0; loyalty path/params navigation needs >= 9.8.0 (gracefully ignored below that). Ignored if androidDependency is set. |

SSL certificate pinning

| Option | Type | Default | Description | |---|---|---|---| | enablePinning | boolean | false | Enables iOS file-based pinning. Requires certificatePath. | | certificatePath | string | unset | Path (relative to project root) to your .der certificate. Used for both the main app and the NSE. | | serverExpectedPublicKey | string | unset | Android public-key pin. Hex-encoded SubjectPublicKeyInfo. Independent of the iOS file-based options. |

Server region

| Option | Type | Default | Description | |---|---|---|---| | useUsServer | boolean | false | Use the US data centre (https://sdk.us.xtremepush.com). | | serverUrl | string | unset | Custom XtremePush server URL. Takes precedence over useUsServer. | | usServerUrl | string | 'https://sdk.us.xtremepush.com' | Override the default US URL when useUsServer is true. |

Delivery receipts

| Option | Type | Default | Description | |---|---|---|---| | deliveryReceiptsEndpoint | string | unset | If set, receipts POST to this URL instead of XtremePush. Both platforms. |

Loyalty Widget

Enabled by enableLoyalty: true. Both options below are required when loyalty is enabled — prebuild fails with XP_E004 / XP_E005 otherwise. See Loyalty Widget for the runtime API.

| Option | Type | Default | Description | |---|---|---|---| | loyaltyEndpoint | string | unset | The project/environment-specific loyalty endpoint as a full https:// URL — the host follows the format p<projectId>.p.loyalty.<env>.xtremepush.com, e.g. https://p123.p.loyalty.eu.xtremepush.com. The scheme is required (iOS rejects a scheme-less value, and a bare host triggers XP_W007). Injected via setLoyaltyEndpoint(...) at launch. Confirm the value with your onboarding team. | | loyaltyTokenRefreshCallback | string (event name) | unset | Event name the SDK emits when it needs a fresh JWT (the expiry fallback). Must match the eventName passed to onLoyaltyTokenRefresh. |

Callbacks

These three pairs require both a plugin option (which tells the native SDK to register a listener) and a JS subscription with the same event name. Without the plugin option, the native SDK never registers and your JS handler never fires.

| Option | Type | JS subscription helper | |---|---|---| | messageResponseCallback | string (event name) | onMessageResponse | | inboxBadgeCallback | string (event name) | onInboxBadgeUpdate | | deeplinkCallback | string (event name) | onDeeplinkReceived | | inboxInvalidatedCallback | string (event name) | onInboxInvalidated | | loyaltyTokenRefreshCallback | string (event name) | onLoyaltyTokenRefresh |

Full example

// app.config.js — every option populated
export default {
  expo: {
    plugins: [
      ['xtremepush-expo-plugin', {
        // Required
        applicationKey: 'YOUR_APP_KEY',
        googleSenderId: 'YOUR_FCM_SENDER_ID',

        // Platform-specific keys (optional)
        iosAppKey: 'IOS_KEY',
        androidAppKey: 'ANDROID_KEY',

        // Push behaviour
        enablePushPermissions: true,
        enableInAppMessaging: true,
        enableLocationServices: false,
        enableStartSession: true,
        enableInboxBadge: true,
        enableDebugLogs: false,

        // Rich media + delivery receipts (creates iOS NSE)
        enableRichMedia: true,
        enableDeliveryReceipts: true,
        deliveryReceiptsEndpoint: 'https://your-server.com/receipts',
        iosAppGroupIdentifier: 'group.com.yourcompany.yourapp.xtremepush.suit',
        devTeam: 'ABCDE12345',
        nseTargetName: 'XtremePushNotificationServiceExtension',
        iosSdkVersion: '6.1',

        // Encryption (require dashboard-side keys)
        enableEncryptedPush: true,
        enableEncryptedMessages: true,

        // SSL pinning
        enablePinning: true,
        certificatePath: 'assets/cert.der',
        serverExpectedPublicKey: '30820122...010001',

        // Server region
        useUsServer: true,

        // Custom Android dependency (e.g. Huawei) — builds against SDK >= 9.7.0;
        // use >= 9.8.0 for full loyalty in-widget navigation
        androidDependency: 'com.huawei.hms:XtremePush_lib:9.8.2',

        // Loyalty Widget
        enableLoyalty: true,
        loyaltyEndpoint: 'https://p123.p.loyalty.eu.xtremepush.com',
        loyaltyTokenRefreshCallback: 'onLoyaltyTokenRefresh',

        // Callbacks
        messageResponseCallback: 'onMessageResponse',
        inboxBadgeCallback: 'onInboxBadgeUpdate',
        deeplinkCallback: 'onDeeplinkReceived',
        inboxInvalidatedCallback: 'onInboxInvalidated',
      }],
    ],
  },
};

iOS setup

Apple Developer Portal

For the most common configuration (rich media + delivery receipts), you need an App Group identifier registered before building. Recent eas-cli versions can create it for you — the credentials wizard prints Created: group.<…> when it does. If you don't see that line, register it manually using the steps below.

  1. Sign in at developer.apple.com.
  2. Identifiers → +App Groups → Continue.
  3. Identifier: group.<your.bundle.id>.xtremepush.suit — must match iosAppGroupIdentifier in your plugin config exactly. The .suit suffix is required by the XtremePush iOS SDK.
  4. Save.

EAS auto-creates the App IDs (main app + NSE) on the first build. If you need to do this manually:

  1. Identifiers → + → App IDs → App → Continue.
  2. Bundle ID (Explicit): your bundle identifier.
  3. Tick capabilities: Push Notifications, App Groups.
  4. Save.
  5. Repeat for the NSE bundle ID: <your.bundle.id>.XtremePushNotificationServiceExtension.

After both App IDs exist, click each one and configure the App Groups capability to point at the App Group from step 3 above.

APNs credentials

APNs Certificate (.p12)

Use this only if your provider mandates certificate-based APNs.

  1. Apple Developer Portal → Certificates+.
  2. Choose either Apple Push Notification service SSL (Sandbox) for development, or Apple Push Notification service SSL (Sandbox & Production) for App Store / TestFlight.
  3. Continue → select your App ID → continue.
  4. Upload a Certificate Signing Request — generate one in Keychain Access → menu → Certificate AssistantRequest a Certificate from a Certificate Authority → save to disk.
  5. Upload the CSR → download the .cer → double-click to install in Keychain.
  6. In Keychain Access → My Certificates → expand to expose the private key → right-click → Export as .p12. Set a password.
  7. Upload to XtremePush dashboard → Settings → iOS with the password.

Building with EAS (iOS)

Add a preview profile to eas.json for sideloadable Ad-Hoc builds (for production App Store submission, see Production builds):

{
  "cli": { "version": ">= 18.0.0", "appVersionSource": "remote" },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal",
      "ios": { "simulator": false }
    },
    "preview": {
      "distribution": "internal",
      "ios": { "simulator": false, "buildConfiguration": "Release" }
    },
    "production": {
      "autoIncrement": true,
      "ios": { "resourceClass": "m-medium" }
    }
  }
}

Register your test device once:

eas device:create
# Choose "Website" → open the URL on the iPhone in Safari → install the profile

Build:

eas build --platform ios --profile preview --clear-cache

When the credentials wizard runs, answer:

| Prompt | Answer | |---|---| | Log in to your Apple account? | yes, then your Apple ID + 2FA | | (After login, watch for) Synced capabilities | Should print Enabled: Push Notifications (or App Groups, Push Notifications), or No updates. If it says Disabled, Ctrl+C — see Troubleshooting | | Reuse this distribution certificate? | Y | | Generate a new Apple Provisioning Profile? | Y | | Select devices for the ad hoc build | Pick your test iPhone (Space to toggle) |

The wizard runs once for the main target and once for the NSE — answer the same way for both.

Verifying the build with xtremepush-verify-build

Once the build completes (5–15 min), download the .ipa and inspect it before installing:

npx xtremepush-verify-build ~/Downloads/your-build.ipa

Expected output (production build):

── Main app (YourApp.app) ─────────────────
ℹ Bundle ID:        com.yourcompany.yourapp
✓ aps-environment:  production
ℹ Apple Team ID:    ABCDE12345
✓ App Groups:       group.com.yourcompany.yourapp.xtremepush.suit

── Extension (XtremePushNotificationServiceExtension.appex) ──
ℹ Bundle ID:        com.yourcompany.yourapp.XtremePushNotificationServiceExtension
✓ aps-environment:  production
ℹ Apple Team ID:    ABCDE12345
✓ App Groups:       group.com.yourcompany.yourapp.xtremepush.suit

Both rows must show ✓ for aps-environment and App Groups. Failure modes flagged by the tool:

| Symptom | Meaning | Fix | |---|---|---| | Main app aps-environment missing | Push capability not enabled on the main App ID | Apple Developer Portal → tick Push Notifications → re-run eas build --clear-cache | | NSE aps-environment missing | Push capability not enabled on the NSE App ID | Same fix on the NSE App ID | | Main and NSE aps-environment differ | Profiles regenerated against different environments | Regenerate both profiles together; eas build --clear-cache | | App Groups don't overlap | App Group not enabled on both App IDs | Wire it on both; rebuild with --clear-cache |

macOS-only: the verifier shells out to security cms -D to decode embedded mobileprovision files. iOS .ipas are inspected on macOS in practice, so this isn't a real restriction.

Then install:

eas build:run --platform ios --latest

Connect the iPhone via cable. EAS detects it and installs the .ipa directly. Open the app, accept the push prompt, background and foreground once, then check the XtremePush dashboard. Within ~1 minute the device should show Addressable: Yes.

Building locally with Xcode

Local builds work for testing without going through EAS:

npx expo run:ios --device

Pick your connected iPhone from the device list. Xcode handles signing automatically using a Personal Team — push will work end-to-end as long as the App Group, App IDs, and APNs credential are configured (see above).

Local builds use personalTeam profiles which are sandbox-only and limited to 7-day install duration. For longer-term testing on a real device, use the EAS preview profile.

Production builds

For App Store submission:

eas build --platform ios --profile production
eas submit --platform ios --latest

EAS uses an App Store distribution certificate and an App Store provisioning profile. The verifier should still show aps-environment: production on both rows (production .p8 or .p12 covers Ad-Hoc, App Store, and internal-distribution).


Android setup

Firebase project

  1. Sign in at console.firebase.google.com → create a project (or reuse one).
  2. Add an Android app:
    • Package name must match android.package in app.config.js exactly.
    • SHA-1 is optional for FCM; required only for additional Firebase services.
  3. Download google-services.json from the Android app's settings.
  4. Open Project Settings → Cloud Messaging → copy the numeric Sender ID (12 digits). This goes into googleSenderId in your plugin config.

google-services.json placement

Place the file at the project root (not android/app/) and reference it from app.json:

"android": {
  "package": "com.yourcompany.yourapp",
  "googleServicesFile": "./google-services.json"
}

expo prebuild copies it into android/app/ on every prebuild. Putting it at the root keeps it portable across machines and visible to EAS uploads (the default .gitignore excludes android/, which would otherwise hide the file from the EAS build server).

Do not commit secrets. If your Firebase project is shared, treat google-services.json as sensitive; use EAS file environment variables to inject it at build time instead of committing it.

XtremePush dashboard credentials (Android)

Modern Firebase projects use the FCM HTTP v1 API, which requires a Service Account JSON key:

  1. Firebase Console → Project Settings → Service Accounts tab → Generate new private key.
  2. Save the JSON file.
  3. XtremePush dashboard → Settings → Android for your app → upload the Service Account JSON.

(If you're on the legacy FCM Server Key flow, upload that instead — but Google has deprecated it.)

Building with EAS (Android)

eas build --platform android --profile preview

The credentials wizard for Android is much simpler than iOS — just one prompt:

✔ Generate a new Android Keystore? › (Y/n)

Answer Y. EAS generates a keystore for internal-distribution builds. No Apple Developer-equivalent setup, no certificates to manage.

When the build completes:

eas build:run --platform android --latest

With your phone connected via USB and USB Debugging enabled (Settings → System → Developer options). The .apk installs directly. Or scan the QR code from the EAS terminal output to install over Wi-Fi.

After install: open the app, accept the notification permission prompt (Android 13+), confirm the device shows in the XtremePush dashboard, send a test campaign. Both Android push and rich-media images render through the FCM/XtremePush pipeline; the iOS NSE setup has no equivalent on Android.


JavaScript API

Import from xtremepush-expo-plugin/plugins/xtremepush. Every public function is listed below. TypeScript users can also import types from the package root.

import {
  // Identity
  setUser, setExternalId, setLanguage, importUser, updateUser,
  updatePushSubscriptionPreferences,
  updateEmailSubscriptionPreferences,
  updateSmsSubscriptionPreferences,

  // Tracking
  hitEvent, hitEventWithValue, hitEventWithValues, hitImpression,
  hitTag, hitTagWithValue,

  // Push
  requestNotificationPermissions,
  getInitialNotification,

  // Inbox UI
  openInbox,

  // Inbox APIs
  getInboxMessages, getInboxBadge, deleteInboxMessage,
  reportMessageOpened, reportMessageClicked,

  // Subscriptions
  onMessageResponse, onInboxBadgeUpdate, onDeeplinkReceived,
  onInboxInvalidated, onLoyaltyTokenRefresh,

  // Loyalty Widget
  setLoyaltyToken, openLoyalty, getLoyaltyUrl,
  getLoyaltyInjectedJavaScript, handleLoyaltyWebViewMessage,

  // Diagnostics
  isAvailable, constants,

  // Deprecated — not implemented in this version, will be removed in a future
  // release. Both resolve to null; do not use in new code.
  checkPushNotificationStatus, getCurrentDeviceToken,
} from 'xtremepush-expo-plugin/plugins/xtremepush';

import type {
  XtremePushPluginConfig,
  XtremePushNotificationPayload,
  XtremePushNativeModule,
  XtremePushMessageResponseEvent,
  XtremePushInboxBadgeEvent,
  XtremePushDeeplinkEvent,
  XtremePushInboxInvalidatedEvent,
  XtremePushSubscription,
  InboxMessage,
} from 'xtremepush-expo-plugin';

Module availability

import { isAvailable } from 'xtremepush-expo-plugin/plugins/xtremepush';

if (isAvailable()) {
  // The native module is loaded.
} else {
  // Expo Go, web, or a build that hasn't run prebuild — fall back gracefully.
}

The plugin does not work in Expo Go. Use a development build (npx expo run:ios / npx expo run:android) or an EAS build.

User identity

import { setUser, setExternalId } from 'xtremepush-expo-plugin/plugins/xtremepush';

setUser('[email protected]');           // your primary user ID
setExternalId('CRM-12345');            // legacy / external CRM ID

// On logout — pass empty string, null, or undefined to reset.
// (1.2.8+: iOS reached parity with Android on this; previous versions
// silently ignored empty strings on iOS.)
setUser('');
setExternalId('');

Identity changes invalidate the inbox (1.3.0+)

When setUser or setExternalId is called with a value that differs from the previously set identity, the bridge does three things automatically:

  1. Clears the native inbox-item cache. Otherwise reportMessageOpened, reportMessageClicked, and deleteInboxMessage could latch onto a message that belonged to the previous user.
  2. Triggers an SDK-authoritative badge refresh. The new badge value lands via the existing InboxBadgeUpdateListener (Android) or XPushInboxBadgeChangeNotification (iOS) and is forwarded to JS through onInboxBadgeUpdate if you subscribe to it.
  3. Emits onInboxInvalidated so your React tree can drop locally cached inbox state immediately, before the SDK round-trip in step 2 completes.

Empty / null / undefined values are treated as a single "logged out" identity, so repeat logout calls do not look like a transition. The very first call (identity establishment) doesn't fire invalidation either.

To receive the JS event, set inboxInvalidatedCallback in plugin config and subscribe with onInboxInvalidated. The native side-effects (cache clear, badge refresh) run regardless.

Preferred language

import { setLanguage } from 'xtremepush-expo-plugin/plugins/xtremepush';

setLanguage('es-MX');   // IETF BCP 47 tag

setLanguage(code) records the contact's preferred language as profile data. There is no device-level language setter in the native SDKs — the device locale is derived automatically — so this is a thin convenience wrapper that forwards importUser({ language: code }). A few implications follow from that:

  • It is fire-and-forget (void). The underlying importUser promise is awaited internally and any rejection is caught and logged. Call importUser directly if you need the resolved/rejected result.
  • Pair it with an established identity (setUser / setExternalId) so the attribute lands on the right contact, exactly as with importUser.
  • Invalid input is ignored silently. Empty strings, whitespace-only strings, and non-string values are dropped without a call, so you can pass navigator.language or a device-locale value unguarded.

Importing a user profile (1.3.0+)

importUser(preferences) sends a profile payload to the SDK's profileImport endpoint — XPush.importUser on iOS, PushConnector.importUser on Android — and resolves with the SDK's response (or null when the SDK returns no body). Use it to seed or update a contact's attributes, tags, and channel subscriptions.

import { setUser, importUser } from 'xtremepush-expo-plugin/plugins/xtremepush';

// Establish identity FIRST so the import is associated with the right contact.
// (Since 1.3.2 the bridge also injects that identity into the payload for you —
// see "Automatic identifier injection" below.)
setUser('[email protected]');

try {
  const response = await importUser({
    email: '[email protected]',
    firstName: 'Xtreme',
    lastName: 'Push',
    attributes: {
      tier: 'gold',
      tags: ['vip', 'newsletter'],
    },
    subscriptions: { sms: true, email: true, push: false },
  });

  console.log('importUser ok:', response);
} catch (error) {
  switch (error.code) {
    case 'ERR_NO_IDENTIFIER':
      // No identifier could be supplied or resolved (see below). Call setUser()
      // first, or retry once the device has registered with XtremePush.
      break;
    case 'ERR_IMPORT_USER':
      // The backend rejected the import. error.message carries the reason;
      // the SDK's structured fields (code, message, and the raw native
      // response) are attached to error.userInfo where the platform provides them.
      break;
    case 'ERR_IMPORT_USER_INVALID_PAYLOAD': // Android — JSON conversion failed
    case 'ERR_NOT_INITIALIZED':             // Android — SDK not initialised
    case 'ERR_XPUSH_UNAVAILABLE':           // iOS — native module missing
    default:
      // 'TypeError' (no error.code) — input was not a plain object
      console.warn('importUser failed:', error.code ?? error.name, error.message);
  }
}

Input contract. importUser rejects synchronously with TypeError for null, undefined, no-args, arrays, strings, numbers, and booleans. Pass a plain object. Nested objects and arrays are supported on both platforms.

Automatic identifier injection (1.3.2+)

The profileImport endpoint requires a profile identifier in the request body, and rejects an identifier-less request with HTTP 400 / "At least one profile identifier is required". The SDK does not copy the setUser / setExternalId identity into this body for you.

To remove this footgun, the native bridge injects an identifier automatically only when your payload does not already contain one (user_id, external_id, or device_id). Resolution order:

| Priority | Injected key | Source | |---|---|---| | 1 | user_id | The last value passed to setUser, or — if none — setExternalId. | | 2 | device_id | The XtremePush server-assigned device id, read from the SDK's getDeviceInfo() (Android) / deviceInfo (iOS) under the XPushDeviceID key. The anonymous fallback used when no user identity has been set. |

This produces three behaviours, depending on what you pass and what identity is set:

// 1. AUTHENTICATED — identity is set, payload has no identifier.
//    → bridge injects { user_id: '[email protected]' }
setUser('[email protected]');
await importUser({ firstName: 'Xtreme', attributes: { tier: 'gold' } });

// 2. ANONYMOUS — no identity set, payload has no identifier.
//    → bridge injects { device_id: '<XPushDeviceID>' } once the device has
//      registered. If the device id is not yet available, rejects ERR_NO_IDENTIFIER.
await importUser({ attributes: { onboardingStep: 2 } });

// 3. CLIENT-SUPPLIED — you provide the identifier yourself.
//    → bridge leaves the payload UNTOUCHED (never overrides your value).
await importUser({ user_id: 'crm-42', email: '[email protected]' });

ERR_NO_IDENTIFIER. When no identifier is in the payload, no setUser / setExternalId identity is set, and the device id is not yet available (the device has not finished registering with XtremePush), the call rejects with ERR_NO_IDENTIFIER rather than letting the backend return a raw HTTP 400. Recover by calling setUser(...) first, or by retrying once registration has completed (e.g. after the first successful push registration).

The device id only exists after the device successfully registers with XtremePush. A fully-anonymous importUser fired at cold start may therefore reject with ERR_NO_IDENTIFIER until registration completes — this is the expected, catchable signal, not an error in your integration.

Updating a user (1.3.3+)

Use updateUser to update channel subscriptions, preferences, and attributes on an existing contact. It maps to XPush.updateUserWith (iOS) / PushConnector.updateUser (Android), which POST to the SDK's userUpdate endpoint (/push/api/userUpdate) — distinct from importUser's profileImport.

import { setUser, updateUser } from 'xtremepush-expo-plugin/plugins/xtremepush';

setUser('[email protected]');

try {
  const response = await updateUser({
    user_id: '[email protected]',
    push_subscription: 1,                   // integer 1/0, NOT true/false
    push_subscription_preferences: {        // dynamic passthrough — any keys
      marketing: 1,
      transactional: 0,
    },
    email_subscription: 1,
    my_attribute: { tier: 'gold' },
  });

  console.log('updateUser ok:', response);
} catch (error) {
  // error.code is one of:
  //   ERR_UPDATE_USER                — backend rejected the update
  //   ERR_UPDATE_USER_INVALID_PAYLOAD — payload was not valid JSON
  //   ERR_NOT_INITIALIZED            — SDK not initialised (Android)
  //   ERR_XPUSH_UNAVAILABLE          — native module missing (iOS)
  //   TypeError                      — bad input shape (didn't reach native)
}

Host routing. The request hits whatever host the SDK was initialised with — the default (api.xtremepush.com), the US region (useUsServer / usServerUrl), or any custom serverUrl (e.g. a custom host). The native SDK builds the URL from its init config, so the configured host is inherited automatically; nothing is hardcoded in the bridge.

Type fidelity (integers). The userUpdate contract expects integer 1/0 for flag fields like push_subscription — not booleans, and not 1.0. The payload crosses the bridge as a JSON string and is parsed natively (org.json on Android, NSJSONSerialization on iOS), which bypasses the ReadableMap number coercion that would otherwise turn 1 into 1.0. Pass 1/0 explicitly, and pass large 64-bit identifiers as strings (a JS number is a double).

Input contract. Like importUser, updateUser rejects synchronously with TypeError for null, undefined, no-args, arrays, and primitives. Preference keys are passed through dynamically — nothing is whitelisted.

Channel subscription preferences (1.3.3+)

Use these methods to manage a contact's per-category subscription preferences on a base channel — the dedicated SDK operation behind a self-built preference screen (e.g. a list of toggles for news, marketing, transactional):

  • updatePushSubscriptionPreferences(preferences)
  • updateEmailSubscriptionPreferences(preferences)
  • updateSmsSubscriptionPreferences(preferences)

Each takes a flat { topic: boolean } map and resolves with the SDK's response (or null). Pass plain booleans — the bridge normalises each value to the wire type the SDK expects per platform (integer 1/0 on Android, boolean on iOS), so you write the same JavaScript for both.

import {
  updatePushSubscriptionPreferences,
} from 'xtremepush-expo-plugin/plugins/xtremepush';

// A preference screen with three category toggles → one call:
try {
  const response = await updatePushSubscriptionPreferences({
    news: true,          // opted in
    marketing: false,    // opted out
    transactional: true,
  });
  console.log('push preferences updated:', response);
} catch (error) {
  // error.code:
  //   ERR_UPDATE_SUBSCRIPTION_PREFERENCES               — backend rejected (e.g. a
  //                                                        topic not defined under the channel)
  //   ERR_UPDATE_SUBSCRIPTION_PREFERENCES_INVALID_PAYLOAD — payload was not valid JSON
  //   ERR_NOT_INITIALIZED  — SDK not initialised (Android)
  //   ERR_XPUSH_UNAVAILABLE — native module missing (iOS)
  //   TypeError            — bad input shape (didn't reach native)
  console.warn('preference update failed:', error.code, error.message);
}

The email and sms variants are identical in shape:

await updateEmailSubscriptionPreferences({ newsletter: true, offers: false });
await updateSmsSubscriptionPreferences({ alerts: true });

A complete React preference screen wiring booleans straight into the call:

import { useState } from 'react';
import { View, Text, Switch, Button } from 'react-native';
import { updatePushSubscriptionPreferences } from 'xtremepush-expo-plugin/plugins/xtremepush';

const CATEGORIES = ['news', 'marketing', 'transactional'] as const;

function PushPreferences() {
  const [prefs, setPrefs] = useState({ news: true, marketing: false, transactional: true });

  const toggle = (key: string) => setPrefs((p) => ({ ...p, [key]: !p[key] }));

  return (
    <View>
      {CATEGORIES.map((key) => (
        <View key={key} style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
          <Text>{key}</Text>
          <Switch value={prefs[key]} onValueChange={() => toggle(key)} />
        </View>
      ))}
      <Button
        title="Save push preferences"
        onPress={() => updatePushSubscriptionPreferences(prefs).catch(console.warn)}
      />
    </View>
  );
}

Two prerequisites for the topics to take effect:

  1. Define the categories first. The topic keys (news, marketing, …) must already exist as push-channel subscription preferences in your XtremePush Consent Manager. If a key isn't defined under the channel, the SDK returns a per-field error and the call rejects with ERR_UPDATE_SUBSCRIPTION_PREFERENCES. The plugin cannot create categories — that is dashboard configuration.
  2. The master switch still gates delivery. A device only receives push at all if the overall push_subscription is enabled (it is by default). These methods layer category-level control on top of that master switch. To change the master switch itself, use updateUser({ push_subscription: 1 }) (integer 1/0) — see Updating a user.

Events, impressions, and tags

import {
  hitEvent, hitEventWithValue, hitEventWithValues,
  hitImpression, hitTag, hitTagWithValue,
} from 'xtremepush-expo-plugin/plugins/xtremepush';

// Bare event
hitEvent('app_opened');

// Event with a single string value
hitEventWithValue('purchase_completed', '49.99');

// Event with key-value pairs (cross-platform)
hitEventWithValues('product_added_to_basket', {
  product_category: 'Sports',
  product_name: 'Home Jersey',
});

// Page / screen impression
hitImpression('home_page');
hitImpression(`article_${articleId}`);

// User-segmentation tag
hitTag('vip');
hitTagWithValue('user_level', 'gold');

All values are forwarded as strings on Android (the SDK signature is HashMap<String, String>). Pass strings explicitly to keep cross-platform parity. Numbers and booleans are coerced; nested objects/arrays are dropped.

The native SDK silently ignores calls with an empty event name on iOS. Always pass non-empty strings.

Push permissions and registration

import { requestNotificationPermissions } from 'xtremepush-expo-plugin/plugins/xtremepush';

requestNotificationPermissions();

Deferred prompt (recommended UX pattern)

Set enablePushPermissions: false in plugin config to suppress the launch-time prompt, then call requestNotificationPermissions() from JS at a moment that makes sense for the user (e.g. after onboarding):

// app.config.js
enablePushPermissions: false,
// after onboarding
requestNotificationPermissions();

Android note: if you set enablePushPermissions: false, the plugin omits POST_NOTIFICATIONS from the manifest. To prompt later on Android 13+, add it manually:

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

Initial notification handling

When a user taps a push to launch the app from a terminated state, retrieve the payload once on launch:

import { useEffect } from 'react';
import { getInitialNotification } from 'xtremepush-expo-plugin/plugins/xtremepush';

export default function App() {
  useEffect(() => {
    getInitialNotification().then((payload) => {
      if (payload?.deeplink) {
        // navigate to the deeplink…
      }
    });
  }, []);
  return /* … */;
}

The payload is cleared after the first read — call it once on app start. Shape:

{
  id?: string;                    // notification id
  campaignId?: string;
  title?: string;
  text?: string;
  deeplink?: string;
  platform?: 'ios' | 'android';
  receivedAt?: number;            // ms since epoch
  badge?: number;                 // iOS only
  data?: Record<string, any>;     // custom payload
}

Encrypted push: getInitialNotification() reads the raw OS transport payload, which is the pre-decryption copy — so with encrypted push enabled its content fields (title, text, deeplink, data) may come back as ciphertext. Do not use it to read a deeplink under encryption; subscribe to the onDeeplinkReceived callback instead, which delivers the SDK's decrypted output and is cold-start-safe (1.3.3+). See Receiving deeplinks with encrypted push.

Inbox — built-in UI

import { openInbox } from 'xtremepush-expo-plugin/plugins/xtremepush';

openInbox();   // opens the SDK's full-screen inbox UI

Inbox — custom UI

import {
  getInboxMessages, getInboxBadge, deleteInboxMessage,
  reportMessageOpened, reportMessageClicked,
  onInboxBadgeUpdate,
} from 'xtremepush-expo-plugin/plugins/xtremepush';
import type { InboxMessage } from 'xtremepush-expo-plugin';

// Fetch a page (limit ≥ 1, offset ≥ 0; values are clamped by the JS wrapper).
const messages: InboxMessage[] = await getInboxMessages(20, 0);

// Get the inbox-wide unread total (1.2.8+; earlier versions returned the
// last-page count).
const unread = await getInboxBadge();

// Report opens / clicks. reportMessageClicked also marks the message as
// opened — don't call both for the same interaction.
await reportMessageOpened(message.identifier);
await reportMessageClicked(message.identifier, message.response.action.identifier);

// Delete
await deleteInboxMessage(message.identifier);

InboxMessage shape (1.2.7+):

interface InboxMessage {
  identifier: string;
  isOpened: boolean;
  isClicked: boolean;
  isDelivered: boolean;
  createTimestamp: number;        // Unix milliseconds — pass directly to new Date()
  expirationTimestamp: number | null;
  style: Record<string, any>;
  isCard: boolean;

  response: {
    message: {
      identifier?: string;
      campaignIdentifier?: string;
      title?: string;
      text?: string;
      icon?: string;
      data?: Record<string, any>;
      payload?: Record<string, any>;
    };
    action: {
      deeplink?: string;
      url?: string;
      identifier?: string;
    };
  };
}

Error handling

getInboxMessages rejects with one of two specific codes:

| Code | Meaning | Recommended response | |---|---|---| | ERR_INBOX_FETCH | The SDK surfaced an error (network, auth, etc.) | Retry with backoff | | ERR_SDK_NOT_READY | Device not registered yet (distinct from "inbox is empty", which resolves with []) | Retry after a short delay or after onMessageResponse fires |

try {
  const messages = await getInboxMessages(20, 0);
  setMessages(messages);
} catch (e: any) {
  if (e.code === 'ERR_SDK_NOT_READY') {
    setTimeout(loadInbox, 2000);
  } else {
    console.error('Inbox fetch failed:', e);
  }
}

Event subscriptions

All event subscriptions take an event name (matching your plugin config) and a handler. They return { remove(): void } — call .remove() from the cleanup function of your effect. The four inbox/push/deeplink subscriptions are below; the loyalty token-refresh subscription is covered in Loyalty Widget.

Message response callback

Fires when the user taps a push notification.

// In plugin config:
//   "messageResponseCallback": "onMessageResponse"

import { onMessageResponse } from 'xtremepush-expo-plugin/plugins/xtremepush';

useEffect(() => {
  const sub = onMessageResponse('onMessageResponse', (event) => {
    if (event.message?.deeplink) {
      navigation.navigate(event.message.deeplink);
    }
  });
  return () => sub.remove();
}, [navigation]);

Event shape:

  • message: { id, title, text, deeplink, campaignId, ...customFields }
  • response: Record<string, string> — additional metadata

Inbox badge callback

Fires when the inbox-wide unread count changes.

// In plugin config:
//   "inboxBadgeCallback": "onInboxBadgeUpdate"

import { onInboxBadgeUpdate } from 'xtremepush-expo-plugin/plugins/xtremepush';

useEffect(() => {
  const sub = onInboxBadgeUpdate('onInboxBadgeUpdate', ({ badge }) => {
    setUnreadCount(badge);
  });
  return () => sub.remove();
}, []);

Deeplink callback

Fires when a deeplink is received — with the app in the foreground, and on a cold-start notification tap (the value is buffered natively and replayed once you subscribe, 1.3.3+). This is the recommended way to read a deeplink, and the required one under encrypted push — see Receiving deeplinks with encrypted push.

// In plugin config:
//   "deeplinkCallback": "onDeeplinkReceived"

import { onDeeplinkReceived } from 'xtremepush-expo-plugin/plugins/xtremepush';

useEffect(() => {
  const sub = onDeeplinkReceived('onDeeplinkReceived', ({ deeplink }) => {
    navigation.navigate(deeplink);
  });
  return () => sub.remove();
}, [navigation]);

Inbox invalidated callback

Fires when setUser / setExternalId is called with a value that differs from the previously set identity. By the time the handler runs, the native inbox-item cache has been cleared and an SDK badge refresh is in flight; use this as the signal to drop your JS-side inbox state and refetch.

// In plugin config:
//   "inboxInvalidatedCallback": "onInboxInvalidated"

import { onInboxInvalidated } from 'xtremepush-expo-plugin/plugins/xtremepush';

useEffect(() => {
  const sub = onInboxInvalidated('onInboxInvalidated', ({ reason }) => {
    // reason: 'userChanged' | 'externalIdChanged'
    setInboxMessages([]);
    setUnreadCount(0);
    // refetch under the new identity:
    refreshInbox();
  });
  return () => sub.remove();
}, []);

The native cache clear and SDK badge refresh run unconditionally on identity change. The JS event is opt-in: it only fires if inboxInvalidatedCallback is set in plugin config.


Loyalty Widget

The Loyalty Widget shows a user's loyalty content (points, rewards, offers) in a web view. Enable it with enableLoyalty: true plus loyaltyEndpoint and loyaltyTokenRefreshCallback in plugin config, then drive it from JavaScript.

import {
  setLoyaltyToken,
  openLoyalty,
  getLoyaltyUrl,
  onLoyaltyTokenRefresh,
  getLoyaltyInjectedJavaScript,
  handleLoyaltyWebViewMessage,
} from 'xtremepush-expo-plugin/plugins/xtremepush';

Authentication: set the token

The widget authenticates with a JWT minted by your backend (see the XtremePush "Manage User Authentication" guide). Set it proactively — typically right after login — and the SDK reuses it for every widget open:

// After your user logs in and you've fetched their loyalty JWT:
setLoyaltyToken(jwt);

onLoyaltyTokenRefresh is the expiry fallback, not the primary path. The SDK emits it only when the loaded widget reports its token has expired. Fetch a fresh JWT and either return it (the wrapper forwards it to setLoyaltyToken for you) or call setLoyaltyToken yourself:

useEffect(() => {
  // eventName must match loyaltyTokenRefreshCallback in plugin config.
  const sub = onLoyaltyTokenRefresh('onLoyaltyTokenRefresh', async () => {
    const fresh = await fetchLoyaltyJwtFromYourBackend();
    return fresh; // forwarded to setLoyaltyToken automatically
  });
  return () => sub.remove();
}, []);

The plugin requires loyaltyTokenRefreshCallback whenever enableLoyalty is true, so always register this handler — even if you also set tokens proactively. When the active user changes via setUser / setExternalId, the stored token is cleared on both platforms so a new user never inherits the previous user's JWT.

Full-screen mode

openLoyalty presents the widget natively as a full-screen view. Optionally pass a path and custom parameters:

openLoyalty();                                              // default view
openLoyalty('rewards');                                     // a specific section
openLoyalty('rewards', { 'color-mode': 'dark', lang: 'es' }); // with params

Android SDK < 9.8.0: the path and params arguments to openLoyalty / getLoyaltyUrl deep-link into a specific widget screen and require Android SDK 9.8.0+. On 9.7.x the widget still opens (and getLoyaltyUrl still returns a URL), but those arguments are ignored — you get the default screen, with a one-time log line. See XP_W009. iOS is unaffected.

Deeplinks from full-screen mode are routed through your normal deeplink callback. Set deeplinkCallback in plugin config and subscribe with onDeeplinkReceived — when the widget asks the app to open a URL, it arrives there. Without deeplinkCallback configured, loyalty redirects are silently dropped.

Embedded WebView mode

To render the widget inside your own react-native-webview, fetch the URL with getLoyaltyUrl and wire up the two bridge helpers. In this mode the native SDK is not in the loop, so redirects and token-expiry signals reach you through the WebView's onMessage instead of the native deeplink callback:

import { WebView } from 'react-native-webview';
import {
  getLoyaltyUrl,
  setLoyaltyToken,
  getLoyaltyInjectedJavaScript,
  handleLoyaltyWebViewMessage,
} from 'xtremepush-expo-plugin/plugins/xtremepush';

function LoyaltyScreen({ navigation }) {
  const [url, setUrl] = useState(null);

  useEffect(() => {
    // getLoyaltyUrl requires a token to have been set first (see above).
    getLoyaltyUrl('rewards', { 'color-mode': 'light' }).then(setUrl);
  }, []);

  if (!url) return null;

  return (
    <WebView
      source={{ uri: url }}
      injectedJavaScriptBeforeContentLoaded={getLoyaltyInjectedJavaScript()}
      onMessage={(event) =>
        handleLoyaltyWebViewMessage(event, {
          onTokenExpired: async () => {
            const fresh = await fetchLoyaltyJwtFromYourBackend();
            setLoyaltyToken(fresh); // also re-load the URL to apply it
          },
          onRedirect: (deeplink) => {
            navigation.navigate(deeplink); // route however your app handles deeplinks
          },
        })
      }
    />
  );
}

Both callbacks are optional — an absent onTokenExpired / onRedirect simply ignores that signal. The injected bridge handles the platform differences for you (Android exposes a window.Android interface; iOS hooks window.postMessage), so the same code works on both.


Advanced topics

Server region and custom URLs

By default the SDK connects to the EU data centre. To use the US data centre:

// app.config.js
['xtremepush-expo-plugin', {
  applicationKey: 'YOUR_KEY',
  googleSenderId: 'YOUR_SENDER',
  useUsServer: true,
}]

For a custom URL (overrides useUsServer):

serverUrl: 'https://sdk.your-tenant.xtremepush.com',

After changing server config, run npx expo prebuild --clean. Both platforms use the same URL.

Delivery receipts

Tells the dashboard a push was actually delivered (not just sent). Requires Android SDK ≥ 7.9.0 (XP_W001 below that). On iOS the feature is supported by every SDK version this plugin pins — the default iosSdkVersion is '6.1' — so no version action is needed there.

['xtremepush-expo-plugin', {
  applicationKey: 'YOUR_KEY',
  googleSenderId: 'YOUR_SENDER',
  enableDeliveryReceipts: true,
  iosAppGroupIdentifier: 'group.com.yourcompany.yourapp.xtremepush.suit',
  devTeam: 'ABCDE12345',
}]

What the plugin generates:

  • iOS: XPush.setDeliveryReceiptsEnabled(true), XPush.enableAppGroups(...), application(_:didReceiveRemoteNotification:fetchCompletionHandler:) injected into AppDelegate. The Notification Service Extension is created with its init() configured per the XtremePush iOS Enterprise Push docs.
  • Android: .setDeliveryReceiptsEnabled(true) added to the PushConnector.Builder chain in MainApplication.

After enabling, send a test push and watch the device log for:

[XPush] - userNotificationCenter willPresentNotification: { ... "delivery-receipt" = 1 ... }
[XPush] - Send request: https://sdk.<tenant>.xtremepush.com/push/api/actionHit ...
[XPush] - Request finished with api: .../actionHit ... code = 200; success = 1;

The campaign should transition Sent → Delivered in the dashboard within seconds.

Encrypted push

iOS

enableEncryptedPush: true

Injects XPush.enableEncryptedPush() (Swift) or [XPush enableEncryptedPush] (Obj-C) into the AppDelegate setAppKey block. Both forms are argumentless from iOS SDK 6.1.0+.

Android

enableEncryptedMessages: true

Injects .setEncryptedMessagesEnabled(true) into the builder chain. Requires Android SDK ≥ 8.1.0.

Dashboard step (both platforms)

Upload your public encryption key in the XtremePush dashboard before enabling. Without it the SDK silently drops decryption attempts on incoming pushes.

Receiving deeplinks with encrypted push

When encrypted push is on, the notification's content fields — title, text, deeplink, and data — travel over the wire encrypted and are only readable after the SDK decrypts them. This changes how you must read a deeplink:

  • Use the onDeeplinkReceived callback — not getInitialNotification(). The callback delivers the deeplink from the SDK's decrypted output, so it is always readable. getInitialNotification() reads the raw OS transport payload, which is the pre-decryption copy; under encrypted push its content fields may come back as ciphertext. Do not rely on it for the deeplink when encryption is enabled.
  • For the full decrypted content (title / text / data), subscribe to onMessageResponse alongside the deeplink callback.

Setup — three things must all be true:

  1. Set the callback(s) in your plugin config. The deeplink path is a no-op without deeplinkCallback:

    {
      "deeplinkCallback": "onDeeplinkReceived",
      "messageResponseCallback": "onMessageResponse"
    }
  2. Subscribe at your app root, as early as possible — ideally in the top-level component before navigation or a login gate mounts. This matters for the cold-start case below.

  3. Rebuild the native projects after changing config (npx expo prebuild --clean, then rebuild the dev/release client).

import { useEffect } from 'react';
import {
  onDeeplinkReceived,
  onMessageResponse,
} from 'xtremepush-expo-plugin/plugins/xtremepush';

export default function App() {
  useEffect(() => {
    // Readable deeplink, including a cold-start tap under encrypted push.
    const link = onDeeplinkReceived('onDeeplinkReceived', ({ deeplink }) => {
      // navigate to `deeplink`…
    });
    // Full decrypted content (title / text / data), if you need it.
    const msg = onMessageResponse('onMessageResponse', ({ message, response }) => {
      // message.deeplink / message.title / message.text / message.data…
    });
    return () => { link.remove(); msg.remove(); };
  }, []);
  return /* … */;
}

Cold start (app launched from a killed state by a notification tap): on launch the SDK decrypts and fires the deeplink before the JS bridge has finished subscribing. The plugin buffers that value natively and replays it the moment your listener attaches (1.3.3+), so a root-level subscription receives it reliably. The buffer holds for 60 seconds after launch — if you defer subscribing behind a slow splash or login flow past that window, the replayed deeplink is discarded. Subscribing at the app root keeps you well inside it.

SSL certificate pinning

Pinning the server cert is independent on each platform.

iOS — file-based pinning

Place the .der certificate in your project (e.g. assets/cert.der) and configure:

enablePinning: true,
certificatePath: 'assets/cert.der'

The plugin copies the cert into the iOS bundle, registers it with Xcode's Copy Bundle Resources phase, and binds it to both the main app and the NSE target (1.2.8+). When delivery receipts are also enabled, the same cert powers TLS pinning inside the NSE.

Android — public-key pinning

serverExpectedPublicKey: 'YOUR_EXPECTED_SERVER_KEY_HERE...'

Hex-encoded SubjectPublicKeyInfo. Injected as .setServerExpectedPublicKey(...) in PushConnector.Builder. To extract the key from your server cert:

echo | openssl s_client -connect sdk.your-tenant.xtremepush.com:443 -servername sdk.your-tenant.xtremepush.com 2>/dev/null \
  | openssl x509 -pubkey -noout \
  | openssl pkey -pubin -outform DER \
  | xxd -p -c 1000

If the value doesn't match what your XtremePush tenant administrator gave you, ask before pinning to it (could be MITM).

Custom Notification Service Extension

By default the NSE is named XtremePushNotificationServiceExtension. To use a different name:

nseTargetName: 'MyCustomNSETarget'

The plugin creates the Xcode target, source files, entitlements, and Info.plist under that name. Update your extra.eas.build.experimental.ios.appExtensions block to match (the plugin auto-injects with the resolved name).

Custom delivery receipt endpoints

To route receipts to your own server instead of XtremePush:

enableDeliveryReceipts: true,
deliveryReceiptsEndpoint: 'https://your-server.com/receipts'

Both platforms use the SDK's two-argument form, which POSTs the receipt JSON to your endpoint.


Validation codes

The plugin emits machine-readable codes at prebuild time so config errors are unambiguous. Errors block prebuild; warnings don't.

Errors

XP_E001

iosAppGroupIdentifier doesn't end with .xtremepush.suit while enableDeliveryReceipts is true. The XtremePush iOS SDK requires the suffix.

Fix: change the identifier to end with .xtremepush.suit, or omit it to accept the auto-derived value.

XP_E002

ios.bundleIdentifier is missing while enableDeliveryReceipts is true and no explicit iosAppGroupIdentifier is set. The plugin can't auto-derive the App Group without a bundle identifier.

Fix: add ios.bundleIdentifier to your Expo config, or set iosAppGroupIdentifier explicitly.

XP_E003

devTeam is missing while an NSE will be created (enableRichMedia or enableDeliveryReceipts is true). Without it, the NSE Xcode target is signed with DEVELOPMENT_TEAM = undefined, which fails downstream.

Fix: add devTeam: 'YOUR_TEAM_ID'. Find it at developer.apple.com → Membership.

XP_E004

enableLoyalty is true but loyaltyEndpoint is missing. The SDK has no instance to route loyalty requests to without it.

Fix: add loyaltyEndpoint as a full https:// URL (e.g. 'https://p123.p.loyalty.eu.xtremepush.com'). Confirm the value with your onboarding team.

XP_E005

enableLoyalty is true but loyaltyTokenRefreshCallback is missing. Without an event name the bridge can't forward the SDK's token-refresh request to JS, so the widget can't recover after the token expires.

Fix: add loyaltyTokenRefreshCallback: 'onLoyaltyTokenRefresh' and subscribe with onLoyaltyTokenRefresh.

Warnings

| Code | Triggered when | What to do | |---|---|---| | XP_W001 | Resolved Android SDK is older than the minimum required for enableDeliveryReceipts (7.9.0) | Bump androidDependencyVersion | | XP_W002 | Resolved Android SDK is older than the minimum required for enableEncryptedMessages (8.1.0) | Bump androidDependencyVersion | | XP_W003 | deliveryReceiptsEndpoint doesn't look like a URL | Check the value | | XP_W004 | apsEnvironment: 'development' set on an EAS project with an NSE | Remove the override; the auto-derive picks 'production' | | XP_W005 | A hand-written appExtensions block is present and a field is wrong | Fix the field, or remove the hand-written entry to fall back to auto-injection | | XP_W006 | apsEnvironment plugin option disagrees with a hand-written aps-environment in appExtensions | Make the values match, or remove the hand-written value | | XP_W007 | loyaltyEndpoint doesn't look like a URL/host | Check the value | | XP_W008 | Resolved Android SDK is below the verified-buildable floor (9.7.0) | Bump androidDependencyVersion to 9.7.0+ | | XP_W009 | enableLoyalty on Android SDK 9.7.x — in-widget path/params navigation needs 9.8.0+ | Bump to 9.8.0+ for deep-linking, or ignore (loyalty still works) |

XP_W001

enableDeliveryReceipts requires the Android SDK to be at least 7.9.0. If your `and