Xtremepush Expo Plugin
A config plugin for Expo applications that integrates XtremePush SDK functionality with full React Native module support for both iOS and Android platforms.
Table of Contents
Requirements
System Requirements
- Node.js: 18.0 or higher
- Expo SDK: 51.0 or higher (53.0+ recommended)
- React Native: 0.73.0 or higher
iOS Requirements
- iOS Deployment Target: 15.0 or higher
- Xcode: 14.0 or higher
- CocoaPods: Latest version
- Apple Developer Account with Push Notification capability enabled
Android Requirements
- Minimum SDK: 21 (Android 5.0)
- Target SDK: 34 (Android 14)
- Gradle: 8.0 or higher
- Google Play Services: Required for FCM
Platform Support
| iOS | 15.0+ | Yes | Yes | Yes |
| Android | 5.0+ (API 21) | Yes | Yes | Yes |
Installation
Step 1: Install the Plugin
npm install xtremepush-expo-plugin
yarn add xtremepush-expo-plugin
Step 2: Configure Your App
Add the plugin to your app.json or app.config.js:
{
"expo": {
"name": "YourAppName",
"plugins": [
[
"xtremepush-expo-plugin",
{
"applicationKey": "YOUR_XTREMEPUSH_APP_KEY",
"googleSenderId": "YOUR_FCM_SENDER_ID"
}
]
]
}
}
Step 3: Prebuild Your Project
After adding the plugin configuration, rebuild your native projects:
npx expo prebuild --clean
cd ios && pod install && cd ..
Step 4: Run Your Application
npx expo run:ios
npx expo run:android
Platform Setup
iOS Setup
1. Basic Configuration
The plugin automatically configures these capabilities in your iOS project:
- Push Notifications (background mode)
- Remote notification token registration via AppDelegate
- XtremePush SDK initialisation in AppDelegate
2. App Groups Setup (Required for Rich Media)
For rich media push notifications with images and attachments, you need to configure App Groups:
A. In Apple Developer Console:
-
Log into your Apple Developer Account
-
Create App Group:
- Click "Identifiers" > "+" > "App Groups"
- Identifier:
group.{your-bundle-id}.xtremepush
- Description: "XtremePush Rich Media Notifications"
- Example:
group.com.yourcompany.yourapp.xtremepush
-
Enable App Groups for your App ID:
- Click "Identifiers" > Select your App ID
- Check "App Groups" capability
- Click "Configure" > Select your created App Group
- Save changes
-
Update Provisioning Profiles:
- Regenerate and download updated provisioning profiles
- Install them in Xcode or add to your CI/CD
Important: If your iOS build fails after enabling App Groups, you need to regenerate your provisioning profiles. This commonly occurs when App Groups are added to an existing App ID.
Solution:
npx expo prebuild --clean --platform ios
cd ios && pod install && cd ..
npx expo run:ios
B. Configuration in Your Plugin:
{
"expo": {
"plugins": [
[
"xtremepush-expo-plugin",
{
"applicationKey": "YOUR_APP_KEY",
"googleSenderId": "YOUR_SENDER_ID",
"enableRichMedia": true,
"iosAppGroup": "group.com.yourcompany.yourapp.xtremepush"
}
]
]
}
}
C. EAS Build Configuration (if using EAS):
Add to your app.json for EAS builds:
{
"expo": {
"extra": {
"eas": {
"projectId": "your-project-id",
"build": {
"experimental": {
"ios": {
"appExtensions": [
{
"targetName": "XtremePushNotificationServiceExtension",
"bundleIdentifier": "com.yourcompany.yourapp.XtremePushNotificationServiceExtension",
"entitlements": {
"com.apple.security.application-groups": [
"group.com.yourcompany.yourapp.xtremepush"
]
}
}
]
}
}
}
}
}
}
}
3. Verification Steps
After setup, verify your configuration:
ls ios/XtremePushNotificationServiceExtension/
grep -r "Xtremepush-iOS-SDK" ios/Podfile
grep -r "com.apple.security.application-groups" ios/
Android Setup
1. Firebase Configuration
-
Create Firebase Project:
-
Add Android App:
- Package name must match your
android.package in app.json
- Download
google-services.json
- Place in your project root (not in android/ folder)
-
Get FCM Sender ID:
- Project Settings > Cloud Messaging
- Copy the Sender ID (a numeric string, e.g.
123456789012)
- Use this as
googleSenderId in plugin configuration
Note: The Sender ID is not the same as the Server Key. The Sender ID is the numeric project number shown in Firebase Cloud Messaging settings.
2. Permissions
The plugin automatically adds required permissions to AndroidManifest.xml when enableLocationServices is true (the default):
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
If your app does not use location features, set enableLocationServices: false to avoid unnecessary permission declarations that may trigger App Store / Play Store review scrutiny.
3. ProGuard / R8
The plugin automatically injects ProGuard/R8 rules required by the XtremePush SDK into your release build. No manual configuration is needed. The rules are written to android/app/xtremepush-proguard-rules.pro and wired into the release buildType during prebuild.
Configuration
Configuration Options
applicationKey | string | Yes | - | Your XtremePush application key |
googleSenderId | string | Yes | - | FCM Sender ID (required for Android push) |
iosAppKey | string | No | applicationKey | iOS-specific application key (overrides applicationKey for iOS) |
androidAppKey | string | No | applicationKey | Android-specific application key (overrides applicationKey for Android) |
enableDebugLogs | boolean | No | false | Enable SDK debug logging |
enableLocationServices | boolean | No | true | Add location permissions to Android manifest and iOS usage descriptions |
enablePushPermissions | boolean | No | true | Auto-request push notification permissions on app launch. Set to false to control when the prompt appears via requestNotificationPermissions() from JS. |
enableInAppMessaging | boolean | No | true | Enable in-app messaging |
enableRichMedia | boolean | No | false | Enable iOS rich media push notifications (creates Notification Service Extension) |
iosAppGroup | string | No | group.{bundleId}.xtremepush | iOS App Group identifier for data sharing between app and extension |
devTeam | string | No | - | Apple Developer Team ID for the Notification Service Extension |
serverUrl | string | No | Default EU server | Custom XtremePush server URL |
useUsServer | boolean | No | false | Use US data center (sets server URL to https://sdk.us.xtremepush.com) |
usServerUrl | string | No | https://sdk.us.xtremepush.com | Custom US server URL (used when useUsServer is true) |
enablePinning | boolean | No | false | Enable SSL certificate pinning (iOS) |
certificatePath | string | No | - | Path to SSL certificate file (.der) for certificate pinning |
serverExpectedPublicKey | string | No | - | Server expected public key for SSL pinning on Android. Injected as .setServerExpectedPublicKey(...) in PushConnector.Builder. |
apsEnvironment | 'development' or 'production' | No | Auto (from provisioning profile) | Override the APS environment entitlement. Leave unset to let Xcode determine the correct value from your provisioning profile (recommended). |
androidDependency | string | No | ie.imobile.extremepush:XtremePush_lib:9.3.11 | Override the full Android XtremePush Gradle dependency string (e.g. for Huawei HMS builds) |
androidDependencyVersion | string | No | 9.3.11 | Override just the version of the default Android dependency. Ignored if androidDependency is set. |
messageResponseCallback | string | No | Disabled | Event name emitted when a user taps a push notification. Set to enable. See Callback Functions. |
inboxBadgeCallback | string | No | Disabled | Event name emitted when the inbox badge count changes. Set to enable. See Callback Functions. |
deeplinkCallback | string | No | Disabled | Event name emitted when a deeplink is received. Set to enable. See Callback Functions. |
Full Configuration Example
export default {
expo: {
plugins: [
[
"xtremepush-expo-plugin",
{
"applicationKey": "YOUR_APP_KEY",
"googleSenderId": "YOUR_FCM_SENDER_ID",
"iosAppKey": "IOS_SPECIFIC_KEY",
"androidAppKey": "ANDROID_SPECIFIC_KEY",
"useUsServer": true,
"enableDebugLogs": true,
"enableLocationServices": false,
"enablePushPermissions": true,
"enableInAppMessaging": true,
"enableRichMedia": true,
"iosAppGroup": "group.com.yourcompany.yourapp.xtremepush",
"devTeam": "ABCDE12345",
"messageResponseCallback": "onMessageResponse",
"inboxBadgeCallback": "onInboxBadgeUpdate",
"deeplinkCallback": "onDeeplinkReceived"
}
]
]
}
};
Server Configuration (US Region Support)
By default, the XtremePush SDK connects to the EU data center. If your account is hosted on the US data center, you need to configure the server URL.
Option 1: Use US Server Flag (Recommended)
export default {
expo: {
plugins: [
[
"xtremepush-expo-plugin",
{
"applicationKey": "YOUR_APP_KEY",
"googleSenderId": "YOUR_SENDER_ID",
"useUsServer": true
}
]
]
}
};
Option 2: Custom Server URL
export default {
expo: {
plugins: [
[
"xtremepush-expo-plugin",
{
"applicationKey": "YOUR_APP_KEY",
"googleSenderId": "YOUR_SENDER_ID",
"serverUrl": "https://sdk.us.xtremepush.com"
}
]
]
}
};
Important Notes:
- The server URL is configured at build time via native code injection
- After changing server configuration, run
npx expo prebuild --clean
- Both iOS and Android will use the same server URL
- If both
serverUrl and useUsServer are specified, serverUrl takes precedence
SSL Certificate Pinning
If your project is provisioned in the US region, you may need to implement certificate pinning for security.
iOS — Certificate File Pinning
- Place the certificate file in your project:
your-project/
assets/
cert.der
app.json
package.json
{
"enablePinning": true,
"certificatePath": "assets/cert.der"
}
- Rebuild:
npx expo prebuild --clean
The certificate is automatically copied to the iOS bundle and added to Xcode Build Phases (Copy Bundle Resources) during prebuild.
Android — Public Key Pinning
For Android, use the serverExpectedPublicKey option instead of a certificate file:
{
"serverExpectedPublicKey": "PUBLIC KEY HERE"
}
This value is injected into the PushConnector.Builder as .setServerExpectedPublicKey(...).
Usage
Import the Module
import {
hitEvent,
hitEventWithValue,
hitTag,
hitTagWithValue,
setUser,
setExternalId,
openInbox,
requestNotificationPermissions,
getInitialNotification,
isAvailable,
onMessageResponse,
onInboxBadgeUpdate,
onDeeplinkReceived,
getInboxMessages,
deleteInboxMessage,
getInboxBadge,
reportMessageOpened,
reportMessageClicked,
} from 'xtremepush-expo-plugin/plugins/xtremepush';
import Xtremepush from 'xtremepush-expo-plugin/plugins/xtremepush';
Check Module Availability
import { isAvailable } from 'xtremepush-expo-plugin/plugins/xtremepush';
if (isAvailable()) {
} else {
}
Basic Integration
import { useEffect } from 'react';
import { setUser, hitEvent, requestNotificationPermissions } from 'xtremepush-expo-plugin/plugins/xtremepush';
export default function App() {
useEffect(() => {
setUser('user@example.com');
hitEvent('app_opened');
}, []);
return <YourApp />;
}
User Management
import { setUser, setExternalId } from 'xtremepush-expo-plugin/plugins/xtremepush';
setUser("user@example.com");
setExternalId("CRM-12345");
Event Tracking
import { hitEvent, hitTag, hitTagWithValue } from 'xtremepush-expo-plugin/plugins/xtremepush';
hitEvent("purchase_completed");
hitEvent("article_read");
hitTag("premium_user");
hitTag("newsletter_subscriber");
hitTagWithValue("user_level", "gold");
hitTagWithValue("purchase_amount", "99.99");
Tracking Events with Values
Use hitEventWithValue to track an event name together with a value — for example, "the user made a purchase, and the amount was 49.99".
Parameters
event | string | Yes | The name of the event (e.g. "purchase_completed"). |
value | string | Yes | A value to attach to the event (e.g. "49.99"). Always pass as a string. |
Examples
import { hitEventWithValue } from 'xtremepush-expo-plugin/plugins/xtremepush';
hitEventWithValue("product_viewed", "shoes");
hitEventWithValue("purchase_completed", "49.99");
const amount = 49.99;
hitEventWithValue("purchase_completed", String(amount));
Common Mistakes
-
Passing a number instead of a string for the value. The SDK expects both event and value to be strings. Always wrap numbers in quotes or use String():
hitEventWithValue("purchase", 49.99);
hitEventWithValue("purchase", "49.99");
-
Passing an empty string as the event name. On iOS, the native SDK silently ignores calls where the event name is empty. Always use a non-empty string.
Tip: Choose short, consistent event names using snake_case (for example purchase_completed, article_read). This makes them easier to find and filter in the Xtremepush dashboard.
Push Notifications
import { requestNotificationPermissions, openInbox } from 'xtremepush-expo-plugin/plugins/xtremepush';
requestNotificationPermissions();
openInbox();
Deferred Push Permission
If you want to control when the push permission prompt appears (e.g. after login), set enablePushPermissions: false in your plugin config and call requestNotificationPermissions() from JS at the appropriate time:
{
"enablePushPermissions": false
}
import { requestNotificationPermissions } from 'xtremepush-expo-plugin/plugins/xtremepush';
requestNotificationPermissions();
Android note: On Android, the device subscribes to FCM during SDK init regardless of enablePushPermissions. On API 33+, requestNotificationPermissions() triggers the OS notification permission dialog. On older Android versions the permission is granted implicitly.
Initial Notification Handling
getInitialNotification() captures push notification payloads when your app is opened from a terminated state. The payload is cleared after the first read.
Basic Usage
import { useEffect } from 'react';
import { getInitialNotification } from 'xtremepush-expo-plugin/plugins/xtremepush';
export default function App() {
useEffect(() => {
const checkInitialNotification = async () => {
try {
const payload = await getInitialNotification();
if (payload) {
console.log('App opened from notification:', payload);
if (payload.deeplink) {
console.log('Navigate to:', payload.deeplink);
}
}
} catch (error) {
console.error('Error getting initial notification:', error);
}
};
checkInitialNotification();
}, []);
return <YourApp />;
}
React Navigation Integration
import { useEffect, useState } from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import { getInitialNotification } from 'xtremepush-expo-plugin/plugins/xtremepush';
const Stack = createStackNavigator();
export default function App() {
const [initialRoute, setInitialRoute] = useState('Home');
const [initialParams, setInitialParams] = useState({});
useEffect(() => {
const handleInitialNotification = async () => {
try {
const payload = await getInitialNotification();
if (payload && payload.deeplink) {
const route = parseDeeplink(payload.deeplink);
if (route) {
setInitialRoute(route.screen);
setInitialParams(route.params);
}
}
} catch (error) {
console.error('Error handling initial notification:', error);
}
};
handleInitialNotification();
}, []);
return (
<NavigationContainer>
<Stack.Navigator initialRouteName={initialRoute}>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen
name="ProductDetail"
component={ProductDetailScreen}
initialParams={initialParams}
/>
</Stack.Navigator>
</NavigationContainer>
);
}
function parseDeeplink(deeplink: string) {
try {
const url = new URL(deeplink);
const pathSegments = url.pathname.split('/').filter(Boolean);
if (pathSegments.length >= 2) {
const [section, id] = pathSegments;
switch (section) {
case 'product':
return { screen: 'ProductDetail', params: { productId: id } };
default:
return null;
}
}
} catch {
return null;
}
return null;
}
Payload Structure
import type { XtremePushNotificationPayload } from 'xtremepush-expo-plugin';
{
id?: string;
campaignId?: string;
title?: string;
text?: string;
deeplink?: string;
platform?: 'android' | 'ios';
receivedAt?: number;
badge?: number;
data?: { [key: string]: any };
}
Callback Functions
Callbacks let your app react when a user taps a push notification, when the inbox badge count changes, or when a deeplink arrives.
Setting up callbacks is a two-step process:
- Enable the callback in your plugin configuration — this tells the native SDK to start listening for the event.
- Subscribe to the callback in your JavaScript/TypeScript code — this is where you write the function that runs when the event fires.
Important: If you skip step 1, the native SDK will not register the listener and your JavaScript function will never be called. Both steps are required.
Step 1: Enable Callbacks in Your Plugin Config
Add the callback names to your plugin configuration. The string you provide becomes the event name that your app listens for.
{
"messageResponseCallback": "onMessageResponse",
"inboxBadgeCallback": "onInboxBadgeUpdate",
"deeplinkCallback": "onDeeplinkReceived"
}
After changing your plugin config, rebuild: npx expo prebuild --clean
Step 2: Subscribe to Callbacks in Your App
Each subscription function takes two arguments:
eventName | string | The same event name you used in your plugin config. Must match exactly. |
handler | function | The function that runs when the event fires. |
Each function returns a subscription object with a .remove() method. Call .remove() when your component unmounts.
Message Response Callback
Fires when a user taps a push notification.
Event data:
message | object | The notification content (id, title, text, deeplink, campaignId, custom fields). |
response | object | Additional response metadata (key-value string pairs). |
import { useEffect } from 'react';
import { onMessageResponse } from 'xtremepush-expo-plugin/plugins/xtremepush';
export default function App() {
useEffect(() => {
const subscription = onMessageResponse("onMessageResponse", (event) => {
console.log('User tapped notification:', event);
if (event.message?.deeplink) {
console.log('Navigating to:', event.message.deeplink);
}
});
return () => subscription.remove();
}, []);
return <YourApp />;
}
Inbox Badge Callback
Fires when the inbox unread count changes.
Event data:
badge | number | Current number of unread inbox messages. |
import { useEffect, useState } from 'react';
import { onInboxBadgeUpdate } from 'xtremepush-expo-plugin/plugins/xtremepush';
export default function InboxButton() {
const [unreadCount, setUnreadCount] = useState(0);
useEffect(() => {
const subscription = onInboxBadgeUpdate("onInboxBadgeUpdate", (event) => {
setUnreadCount(event.badge);
});
return () => subscription.remove();
}, []);
return (
<View>
<Text>Inbox {unreadCount > 0 ? `(${unreadCount})` : ''}</Text>
</View>
);
}
Deeplink Callback
Fires when a deeplink is received while the app is running in the foreground.
Event data:
deeplink | string | The deeplink URL. |
import { useEffect } from 'react';
import { onDeeplinkReceived } from 'xtremepush-expo-plugin/plugins/xtremepush';
export default function DeeplinkHandler() {
useEffect(() => {
const subscription = onDeeplinkReceived("onDeeplinkReceived", (event) => {
console.log('Deeplink received:', event.deeplink);
});
return () => subscription.remove();
}, []);
return null;
}
All Callbacks Together
import { useEffect, useState } from 'react';
import {
onMessageResponse,
onInboxBadgeUpdate,
onDeeplinkReceived,
} from 'xtremepush-expo-plugin/plugins/xtremepush';
export default function XtremePushCallbackHandler({ navigation }: { navigation: any }) {
const [inboxBadge, setInboxBadge] = useState(0);
useEffect(() => {
const messageSub = onMessageResponse("onMessageResponse", (event) => {
if (event.message?.deeplink) {
navigation.navigate('Webview', { url: event.message.deeplink });
}
});
const badgeSub = onInboxBadgeUpdate("onInboxBadgeUpdate", (event) => {
setInboxBadge(event.badge);
});
const deeplinkSub = onDeeplinkReceived("onDeeplinkReceived", (event) => {
navigation.navigate('Webview', { url: event.deeplink });
});
return () => {
messageSub.remove();
badgeSub.remove();
deeplinkSub.remove();
};
}, [navigation]);
return null;
}
Custom Inbox
The plugin provides methods for building a custom inbox UI. These allow you to fetch, display, and manage inbox messages without using the built-in XtremePush inbox screen.
Message shape (v1.2.6+)
Each message returned by getInboxMessages() has the following shape. Message content and tap action are nested under response to mirror the native SDK's XPMessageResponse / Android Message.getResponse() structure, so both platforms return the same object shape.
interface InboxMessage {
identifier: string;
isOpened: boolean;
isClicked: boolean;
isDelivered: boolean;
createTimestamp: number;
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;
};
};
}
Breaking change (v1.2.6): Prior versions exposed title, alert, cid, and a flat message field at the top level of each item. These were removed because the underlying native parsing was unreliable and frequently returned undefined. All message content is now under response.message; the tap action is under response.action. Read the Changelog entry for v1.2.6 before upgrading.
Important: You must call getInboxMessages() before using reportMessageOpened, or reportMessageClicked — those methods operate on a cached list from the most recent fetch. The cache is cleared on every getInboxMessages() call, so always call getInboxMessages() immediately before acting on a message.
Step-by-step: retrieve and display inbox messages
- Fetch the list. Call
getInboxMessages(limit, offset) — both arguments are clamped to sensible bounds (limit ≥ 1, offset ≥ 0) by the JS wrapper.
import { getInboxMessages } from 'xtremepush-expo-plugin/plugins/xtremepush';
const messages = await getInboxMessages(20, 0);
- Handle the two error cases separately.
getInboxMessages() rejects with one of two codes:
ERR_INBOX_FETCH — the SDK surfaced an error (network, auth, etc.). Retry with backoff.
ERR_SDK_NOT_READY — the SDK returned a nil list because the device is not registered yet. This is distinct from an empty inbox (which resolves with []). Retry after registration completes, e.g. after onMessageResponse fires or after a short delay on first launch.
try {
const messages = await getInboxMessages(20, 0);
setMessages(messages);
} catch (e) {
if (e.code === 'ERR_SDK_NOT_READY') {
setTimeout(loadInbox, 2000);
} else {
console.error('Inbox fetch failed:', e);
}
}
- Render content from the
response object. Read title, body, icon, and custom data from item.response.message:
<FlatList
data={messages}
keyExtractor={(item) => item.identifier}
renderItem={({ item }) => (
<View>
<Text style={{ fontWeight: item.isOpened ? 'normal' : 'bold' }}>
{item.response.message.title ?? 'No title'}
</Text>
<Text>{item.response.message.text ?? ''}</Text>
{item.response.message.icon && (
<Image source={{ uri: item.response.message.icon }} style={{ width: 48, height: 48 }} />
)}
</View>
)}
/>
- React to a tap by reporting it. Call
reportMessageClicked(identifier) when the user taps a row. This marks the message as both clicked and opened on the server — do not also call reportMessageOpened for the same interaction. Use reportMessageOpened(identifier) only for "message became visible in the list" telemetry.
const onTap = async (item: InboxMessage) => {
await reportMessageClicked(item.identifier, item.response.action.identifier);
if (item.response.action.deeplink) {
navigation.navigate(item.response.action.deeplink);
}
};
- Keep the badge in sync.
getInboxMessages() and deleteInboxMessage() both emit the badge count via onInboxBadgeUpdate after completing, so you only need to wire up the subscription once — no polling required.
onInboxBadgeUpdate('onInboxBadgeUpdate', ({ badge }) => setBadge(badge));
Get Badge Count
import { getInboxBadge } from 'xtremepush-expo-plugin/plugins/xtremepush';
const unreadCount = await getInboxBadge();
Report Message Opened
import { reportMessageOpened } from 'xtremepush-expo-plugin/plugins/xtremepush';
await reportMessageOpened(message.identifier);
await reportMessageOpened(message.identifier, "view");
Report Message Clicked
import { reportMessageClicked } from 'xtremepush-expo-plugin/plugins/xtremepush';
await reportMessageClicked(message.identifier);
Full Custom Inbox Example
import { useEffect, useState } from 'react';
import { View, Text, FlatList, TouchableOpacity } from 'react-native';
import {
getInboxMessages,
getInboxBadge,
reportMessageClicked,
deleteInboxMessage,
onInboxBadgeUpdate,
} from 'xtremepush-expo-plugin/plugins/xtremepush';
import type { InboxMessage } from 'xtremepush-expo-plugin';
export default function CustomInbox() {
const [messages, setMessages] = useState<InboxMessage[]>([]);
const [badge, setBadge] = useState(0);
const loadInbox = async () => {
try {
const items = await getInboxMessages(50, 0);
setMessages(items);
} catch (e: any) {
if (e.code === 'ERR_SDK_NOT_READY') {
setTimeout(loadInbox, 2000);
} else {
console.error('Failed to load inbox:', e);
}
}
};
useEffect(() => {
loadInbox();
const sub = onInboxBadgeUpdate('onInboxBadgeUpdate', ({ badge }) => setBadge(badge));
return () => sub.remove();
}, []);
const handlePress = async (msg: InboxMessage) => {
await reportMessageClicked(msg.identifier, msg.response.action.identifier);
loadInbox();
};
const handleDelete = async (msg: InboxMessage) => {
await deleteInboxMessage(msg.identifier);
loadInbox();
};
return (
<View>
<Text>Inbox ({badge} unread)</Text>
<FlatList
data={messages}
keyExtractor={(item) => item.identifier}
renderItem={({ item }) => (
<TouchableOpacity onPress={() => handlePress(item)}>
<Text style={{ fontWeight: item.isOpened ? 'normal' : 'bold' }}>
{item.response.message.title ?? 'No title'}
</Text>
<Text>{item.response.message.text ?? ''}</Text>
<TouchableOpacity onPress={() => handleDelete(item)}>
<Text>Delete</Text>
</TouchableOpacity>
</TouchableOpacity>
)}
/>
</View>
);
}
API Reference
Functions
hitEvent | (event: string) | void | Track a named event |
hitEventWithValue | (event: string, value: string) | void | Track an event with a value |
hitTag | (tag: string) | void | Hit a tag for user segmentation |
hitTagWithValue | (tag: string, value: string) | void | Hit a tag with a value |
setUser | (user: string) | void | Set the user identifier |
setExternalId | (id: string) | void | Set an external user ID |
requestNotificationPermissions | () | void | Request push notification permissions |
openInbox | () | void | Open the built-in inbox UI |
getInitialNotification | () | Promise<object | null> | Get the notification payload that launched the app |
isAvailable | () | boolean | Check if the native module is loaded |
getInboxMessages | (limit: number, offset: number) | Promise<InboxMessage[]> | Fetch paginated inbox messages |
getInboxBadge | () | Promise<number> | Get the current unread count |
deleteInboxMessage | (messageId: string) | Promise<number | void> | Delete an inbox message |
reportMessageOpened | (messageId: string, actionIdentifier?: string) | Promise<void> | Report a message as viewed |
reportMessageClicked | (messageId: string, actionIdentifier?: string) | Promise<void> | Report a message as clicked (also marks as opened) |
onMessageResponse | (eventName: string, handler: Function) | { remove: () => void } | Subscribe to notification tap events |
onInboxBadgeUpdate | (eventName: string, handler: Function) | { remove: () => void } | Subscribe to inbox badge changes |
onDeeplinkReceived | (eventName: string, handler: Function) | { remove: () => void } | Subscribe to deeplink events |
Types
All types are importable from the package root:
import type {
XtremePushPluginConfig,
XtremePushNotificationPayload,
XtremePushNativeModule,
XtremePushMessageResponseEvent,
XtremePushInboxBadgeEvent,
XtremePushDeeplinkEvent,
XtremePushSubscription,
InboxMessage,
} from 'xtremepush-expo-plugin';
Troubleshooting
iOS Common Issues
- Wrong Environment: Ensure using Development cert for testing, Production for App Store
- Expired Certificates: Certificates are valid for 1 year, renew before expiration
- Bundle ID Mismatch: Certificate must match app's Bundle ID exactly
- App Groups Build Failure: Regenerate provisioning profiles after enabling App Groups
Android Common Issues
- Package Name Mismatch: Firebase package name must match your app exactly
- Missing google-services.json: File must be in project root
- Push not working: Verify
googleSenderId is the numeric Sender ID from Firebase, not the Server Key
General
- After any config change, always run
npx expo prebuild --clean
- Verify native module is available with
isAvailable() before calling SDK methods
- The plugin does not work in Expo Go — you must use a development build (
npx expo run:ios / npx expo run:android)
Delivery Receipts (v1.2.4+)
Push delivery receipts tell the XtremePush platform when a notification is actually delivered to a device, not just sent. Requires iOS SDK ≥ 4.1.8 and Android SDK ≥ 7.9.0.
Basic setup (both platforms)
{
"plugins": [
["xtremepush-expo-plugin", {
"applicationKey": "YOUR_KEY",
"googleSenderId": "YOUR_SENDER_ID",
"enableDeliveryReceipts": true
}]
]
}
When enableDeliveryReceipts is true:
- iOS:
XPush.setDeliveryReceiptsEnabled(true), XPush.enableAppGroups(...), and (v1.2.5+) application(_:didReceiveRemoteNotification:fetchCompletionHandler:) are injected into AppDelegate. The Notification Service Extension is created automatically and its init() is configured per the Xtremepush iOS Enterprise Push docs.
- Android:
.setDeliveryReceiptsEnabled(true) is added to the PushConnector.Builder chain in MainApplication.
Step-by-step: configure delivery receipts on iOS
Follow every step. The most common cause of campaigns stuck at Sent is skipping the Apple Developer Portal provisioning steps — the receipt HTTP call silently fails if the App Group is not actually wired up on the real profile.
- Enable the option in your plugin config. Add
enableDeliveryReceipts: true. If you want receipts delivered to your own server, also set deliveryReceiptsEndpoint. Leave iosAppGroupIdentifier unset to accept the auto-derived value group.<bundleId>.xtremepush.suit.
{
"applicationKey": "YOUR_KEY",
"googleSenderId": "YOUR_SENDER_ID",
"enableDeliveryReceipts": true
}
- Register the App Group in Apple Developer Portal. Go to Certificates, Identifiers & Profiles → Identifiers → App Groups and create
group.<your.bundle.id>.xtremepush.suit (the .suit suffix is mandatory per the XtremePush SDK). The plugin throws XP_E001 error at prebuild time if the identifier you provide does not end with .xtremepush.suit.
- Enable the App Group capability on both App IDs. Under Identifiers, open your main app's App ID and the NSE's App ID (
<bundleId>.XtremePushNotificationServiceExtension). Add the App Groups capability on both and tick the group you just created. Both IDs must reference the same group.
- Regenerate both provisioning profiles. Existing profiles predate the new capability and will fail code-signing with cryptic errors like
Provisioning profile ... doesn't include the ... entitlement. Download the refreshed profiles to Xcode (or let EAS re-fetch them on the next build).
- Run
npx expo prebuild --clean. A full regeneration is required — incremental prebuild occasionally leaves stale AppDelegate.swift content. After prebuild, verify that the generated files contain these markers:
ios/<project>/AppDelegate.swift — three comment markers: // @xtremepush-expo-plugin:delivery-receipts, // @xtremepush-expo-plugin:app-groups, // @xtremepush-expo-plugin:delivery-receipts-receive
ios/<project>/AppDelegate.swift — a method application(_:didReceiveRemoteNotification:fetchCompletionHandler:) that forwards to XPush.applicationDidReceiveRemoteNotification(...) (this bridge is what makes the SDK post the delivery receipt; it was missing from v1.2.4 and added in v1.2.5)
ios/XtremePushNotificationServiceExtension/NotificationService.swift — an override init() block calling XPush.setDeliveryReceiptsEnabled(true), then XPush.setAppKey(...), then XPush.enableAppGroups(...) in that exact order
ios/<project>/<project>.entitlements and ios/XtremePushNotificationServiceExtension/XtremePushNotificationServiceExtension.entitlements — both must contain the same group under com.apple.security.application-groups
- Verify on a real device. Simulators will not receive APNs pushes, so you must test on hardware. Build with
npx expo run:ios --device, install, register for push, and send a test campaign. Watch the Xcode device log for these XPush log lines (taken from the investigation report):
[XPush] - applicationDidRegisterForRemoteNotificationsWithDeviceToken: {...}
[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 presence of delivery-receipt = 1 in the payload and a successful actionHit call confirm the NSE received the push and the SDK posted a receipt. The campaign will transition from Sent to Delivered in the dashboard within a few seconds.
Step-by-step: configure delivery receipts on Android
- Enable the option. Add
enableDeliveryReceipts: true. The plugin injects .setDeliveryReceiptsEnabled(true) into the PushConnector.Builder chain.
- Check the Android SDK version. Delivery receipts require Android SDK ≥ 7.9.0. The plugin emits
XP_W001 at prebuild time if your resolved dependency is older; upgrade via androidDependencyVersion: "7.9.0" or later, or pass a fully-qualified androidDependency string.
- Prebuild and verify. After
npx expo prebuild --clean, open android/app/src/main/.../MainApplication.* and confirm the builder chain contains .setDeliveryReceiptsEnabled(true) // @xtremepush-expo-plugin:delivery-receipts (or the two-argument form with your custom endpoint).
Custom reporting endpoint (both platforms)
If you want receipts sent to your own server instead of Xtremepush:
{
"enableDeliveryReceipts": true,
"deliveryReceiptsEndpoint": "https://receipts.your-server.com/endpoint"
}
iOS — App Group identifier
Delivery receipts require an App Group registered in Apple Developer Portal, shared between the main app and the Notification Service Extension.
Auto-derived (recommended): When enableDeliveryReceipts is true and iosAppGroupIdentifier is not set, the plugin auto-derives:
group.<your.bundle.id>.xtremepush.suit
Explicit override:
{
"enableDeliveryReceipts": true,
"iosAppGroupIdentifier": "group.com.yourcompany.yourapp.xtremepush.suit"
}
Important: The identifier must end with .xtremepush.suit (the .suit suffix is required by the Xtremepush iOS SDK). The plugin will throw a descriptive error (XP_E001) at prebuild time if the suffix is wrong.
You must register the App Group in Apple Developer Portal before building:
- Go to Identifiers → App Groups →
+
- Create
group.<your.bundle.id>.xtremepush.suit
- Enable the App Group capability for both your main App ID and the NSE App ID
- Re-generate provisioning profiles
iOS — Notification Service Extension
The NSE is created automatically when enableDeliveryReceipts is true. By default it is named XtremePushNotificationServiceExtension. To override:
{
"enableDeliveryReceipts": true,
"nseTargetName": "MyCustomNSETarget"
}
For EAS builds, add the extension to your app.json:
"extra": {
"eas": {
"build": {
"experimental": {
"ios": {
"appExtensions": [{
"targetName": "XtremePushNotificationServiceExtension",
"bundleIdentifier": "com.yourcompany.yourapp.XtremePushNotificationServiceExtension",
"entitlements": {
"com.apple.security.application-groups": [
"group.com.yourcompany.yourapp.xtremepush.suit"
]
}
}]
}
}
}
}
}
Encrypted Push / Messages (v1.2.4+)
iOS encrypted push
{
"enableEncryptedPush": true
}
Injects XPush.enableEncryptedPush(true) into AppDelegate (Swift) or [XPush enableEncryptedPush] (Obj-C) during SDK initialisation.
Android encrypted messages
Requires Android SDK ≥ 8.1.0.
{
"enableEncryptedMessages": true
}
Injects .setEncryptedMessagesEnabled(true) into the PushConnector.Builder chain.
iOS SDK Pod Version (v1.2.4+)
Pin the Xtremepush-iOS-SDK CocoaPods dependency to a specific version:
{
"iosSdkVersion": "6.1"
}
This substitutes ~> <value> into the react-native-xtremepush.podspec. The default is 6.1, which matches v1.2.3 behaviour.
Migrating from 1.2.x
No changes are required if you do not use any of the new options. All new fields default to off and produce byte-identical prebuild output to v1.2.3.
One exception — combined enableRichMedia + enableDeliveryReceipts:
If you enable both enableRichMedia: true and enableDeliveryReceipts: true without specifying iosAppGroupIdentifier, the auto-derived App Group changes:
group.<bundleId>.xtremepush | group.<bundleId>.xtremepush.suit |
You must:
- Register
group.<bundleId>.xtremepush.suit as a new App Group in Apple Developer Portal
- Enable it for both your main App ID and the NSE App ID
- Re-generate and install provisioning profiles
This change is required because the .suit suffix is mandated by the Xtremepush iOS SDK for delivery receipts.
License
This project is licensed under the MIT License.
Changelog
All notable changes to this project are documented here.
[1.2.6] — Latest
Fixed
iOS: getInboxMessages returning items with no content
- Fix: Rewrote
getInboxMessages to extract each documented XPMessage property (identifier, campaignIdentifier, title, text, icon, data, payload) and each XPAction property (deeplink, url, identifier) via explicit KVC reads, each wrapped in its own @try/@catch so a single malformed property no longer aborts the whole item. The extracted content is now returned under a nested response: { message, action } object that mirrors the native XPMessageResponse structure and matches the Android shape.
iOS: getInboxMessages silently resolving [] when the SDK is not ready
- Fix:
getInboxMessages now rejects with ERR_SDK_NOT_READY in this case. An empty inbox still resolves with [].
iOS: malformed inbox item crashing the entire list fetch
- Fix: Per-item
@try/@catch now logs and skips malformed items, so the healthy items are still returned.
Plugin: ...props spread order caused normalised iOS defaults to be overwritten
- Fix:
...props is now spread first so the explicit normalised values take precedence. Normalisation is deterministic and safe to run after the spread.
Plugin: nseTargetName ignored during Podfile target-ordering check
- Fix: The target name is resolved from
pluginConfig.nseTargetName with a fallback to the default.
Upgrading from 1.2.5
Breaking change to getInboxMessages return shape. Code that read item.title, item.alert, item.cid, or item.message must migrate to item.response.message.title, item.response.message.text, item.response.message.campaignIdentifier, and item.response.message respectively. The old top-level fields were unreliable in v1.2.5 and are no longer emitted. See the Custom Inbox → Message shape section earlier in this README for the full interface.
No plugin config changes required. Rebuild your iOS app after upgrading.
[1.2.5]
Fixed
iOS: inbox messages missing message field (client-reported)
- Fix: First attempt at fixing the inbox parsing — corrected the top-level extraction path. This fix was incomplete and was superseded in v1.2.6, which rewrites the parser to read each documented property explicitly.
iOS: reportMessageOpened / reportMessageClicked always rejecting with ERR_NO_MESSAGE
- Fix: Corrected the internal lookup so opened/clicked reporting works on iOS.
iOS: getInboxBadge always returns 0 / onInboxBadgeUpdate never fires (client-reported)
- Fix: Badge count is now proactively emitted after every
getInboxMessages call and after every deleteInboxMessage call (which returns the updated count directly from the SDK). This mirrors Android's listener-driven approach and ensures onInboxBadgeUpdate fires whenever the inbox state changes through the plugin's own operations.
iOS: XPush.enableEncryptedPush(true) causes Swift compile error (client-reported)
- Fix: Swift injection changed to
XPush.enableEncryptedPush() (no argument).
iOS: delivery receipts — missing didReceiveRemoteNotification:fetchCompletionHandler: AppDelegate bridge (client-reported)
- Issue: The plugin did not inject
application(_:didReceiveRemoteNotification:fetchCompletionHandler:) into AppDelegate. Without it the SDK never receives background notification events, no delivery receipt HTTP calls are made, and campaigns remain permanently at Sent in the dashboard.
- Fix: When
enableDeliveryReceipts: true, the plugin now injects this method into both Swift and Obj-C AppDelegates, forwarding to XPush.applicationDidReceiveRemoteNotification(_:fetchCompletionHandler:) before calling super. Injection is idempotent across expo prebuild runs.
Upgrading from 1.2.4
No plugin config changes required. All fixes are internal corrections to generated native code. Rebuild your iOS app after upgrading.
[1.2.4]
Fixed
Android: delivery receipts not configurable from plugin options (client-reported)
- Issue:
PushConnector.Builder did not include .setDeliveryReceiptsEnabled(...). Consumers were forced to patch generated native code manually.
- Fix: New
enableDeliveryReceipts option injects .setDeliveryReceiptsEnabled(true) (or with deliveryReceiptsEndpoint for a custom URL) into the Kotlin and Java MainApplication builder chain.
- Minimum Android SDK: 7.9.0
Android: encrypted messages not configurable from plugin options (client-reported)
- Issue:
.setEncryptedMessagesEnabled(true) was absent from the builder chain.
- Fix: New
enableEncryptedMessages option injects it.
- Minimum Android SDK: 8.1.0
iOS: delivery receipts not configurable from plugin options (client-reported)
- Issue:
XPush.setDeliveryReceiptsEnabled(...) and XPush.enableAppGroups(...) were not injected into AppDelegate. No App Group with the .xtremepush.suit suffix was created.
- Fix: New
enableDeliveryReceipts option injects both calls (Swift and Obj-C). App Group auto-derived as group.<bundleId>.xtremepush.suit; override via iosAppGroupIdentifier.
- Minimum iOS SDK: 4.1.8
iOS: NSE not initialised for delivery receipts (client-reported)
- Issue:
NotificationService.swift had no init() method. The Xtremepush iOS SDK requires setDeliveryReceiptsEnabled, setAppKey, and enableAppGroups in strict order inside init().
- Fix: When
enableDeliveryReceipts: true the plugin generates the init() body with the correct call order. When false, output is structurally equivalent to v1.2.3.
iOS: encrypted push not configurable from plugin options (client-reported)
- Issue:
XPush.enableEncryptedPush(true) / [XPush enableEncryptedPush] was absent from AppDelegate injection.
- Fix: New
enableEncryptedPush option injects the call. Note: Obj-C form takes no argument per SDK docs.
iOS: pod version for Xtremepush-iOS-SDK now configurable
- Issue:
react-native-xtremepush.podspec pinned ~> 6.1 with no override.
- Fix: New
iosSdkVersion option (default: '6.1') substituted at prebuild time.
Added
enableDeliveryReceipts | Both | Enable push delivery receipts |
deliveryReceiptsEndpoint | Both | Route receipts to a custom server instead of Xtremepush |
enableEncryptedPush | iOS | Inject XPush.enableEncryptedPush(true) in AppDelegate |
enableEncryptedMessages | Android | Inject .setEncryptedMessagesEnabled(true) in builder chain |
iosAppGroupIdentifier | iOS | Explicit App Group override (must end with .xtremepush.suit) |
nseTargetName | iOS | Override the NSE Xcode target name |
iosSdkVersion | iOS | Pin the Xtremepush iOS SDK CocoaPod version |
Upgrading from 1.2.3
No changes required unless opting in to new features. All new options default to off, producing byte-identical native output to v1.2.3.
One exception — if you enable both enableRichMedia: true and enableDeliveryReceipts: true without specifying iosAppGroupIdentifier, the auto-derived App Group changes from group.<bundleId>.xtremepush to group.<bundleId>.xtremepush.suit. You must register the new group in Apple Developer Portal and re-provision both targets.
[1.2.3] - 2026-03-30
Fixed
- iOS: Prebuild crash (
TypeError: Cannot read properties of null) when enablePinning: true — replaced addResourceFile with IOSConfig.XcodeUtils.ensureGroupRecursively + addResourceFileToGroup.
- iOS: Certificate not added to Xcode Build Phases —
addResourceFileToGroup with isBuildFile: true now registers the file in Copy Bundle Resources so Bundle.main.path() resolves at runtime.
- iOS: Added
fs.existsSync check before Xcode project modification to prevent dangling file references when the cert copy step fails.
- iOS: POSIX-safe certificate path using template literals instead of
path.join().
Removed
autoRegisterPush config option (deferred to 1.3.0). Use enablePushPermissions: false + requestNotificationPermissions() from JS.
[1.2.2]
- Added
androidDependency and androidDependencyVersion options for Huawei vs Google Play build variant support.
- Various iOS and Android initialisation improvements.