New Expensify uses Airship for push notifications on iOS and Android, providing real-time updates for messages, approvals, and expense activity.
Overview
Push notifications keep users informed:
Chat Messages : New messages in reports
Expense Updates : Approvals, comments, status changes
Mention Notifications : When you’re @mentioned
Task Assignments : New tasks assigned to you
Report Submissions : When reports are submitted for approval
Notifications use Urban Airship SDK for reliable cross-platform delivery.
Architecture
Push Notification Flow
Server Airship Mobile Device
| | |
|-- Send Push ----->| |
| (reportID, | |
| message, | |
| onyxData) | |
| | |
| |-- APNs/FCM --------->|
| | (encrypted) |
| | |
| | |-- Display ---->
| | | Notification
| | |
| | |<-- User Tap ---
| | |
| | |-- Open App --->
| | | Navigate to
| | | Report
Native Integration
iOS uses APNs (Apple Push Notification service): import AirshipFrameworkProxy
class AppDelegate : ExpoAppDelegate {
override func application (
_ application : UIApplication,
didFinishLaunchingWithOptions launchOptions : [UIApplication.LaunchOptionsKey: Any ] ?
) -> Bool {
// Airship automatically configured via AirshipConfig.plist
return super . application (application, didFinishLaunchingWithOptions : launchOptions)
}
}
Android uses FCM (Firebase Cloud Messaging): android/app/src/main/java/com/expensify/chat/MainApplication.kt
import com.urbanairship.Autopilot
import com.urbanairship.UAirship
class MainApplication : Application () {
override fun onCreate () {
super . onCreate ()
// Airship automatically initialized
// Configuration from airshipconfig.properties
}
}
iOS Notifications
Configuration
Airship configuration in ios/AirshipConfig.plist:
<? xml version = "1.0" encoding = "UTF-8" ?>
<! DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd" >
< plist version = "1.0" >
< dict >
<!-- Development keys -->
< key > developmentAppKey </ key >
< string > YOUR_DEV_KEY </ string >
< key > developmentAppSecret </ key >
< string > YOUR_DEV_SECRET </ string >
<!-- Production keys -->
< key > productionAppKey </ key >
< string > YOUR_PROD_KEY </ string >
< key > productionAppSecret </ key >
< string > YOUR_PROD_SECRET </ string >
<!-- Environment -->
< key > inProduction </ key >
< false />
</ dict >
</ plist >
Communication Notifications (iOS 15+)
iOS 15+ enriched notification UI with avatars:
ios/NotificationServiceExtension/NotificationService.swift
import UserNotifications
import Intents
class NotificationService : UANotificationServiceExtension {
@available ( iOSApplicationExtension 15.0 , * )
func configureCommunicationNotification (
notificationContent : UNMutableNotificationContent,
contentHandler : @escaping (UNNotificationContent) -> Void
) {
var notificationData: NotificationData
do {
notificationData = try parsePayload ( notificationContent : notificationContent)
} catch {
contentHandler (notificationContent)
return
}
// Create message intent
let intent: INSendMessageIntent = createMessageIntent ( notificationData : notificationData)
let interaction = INInteraction ( intent : intent, response : nil )
interaction. direction = . incoming
// Donate interaction
interaction. donate { error in
do {
let updatedContent = try notificationContent. updating ( from : intent)
contentHandler (updatedContent)
} catch {
contentHandler (notificationContent)
}
}
}
func createMessageIntent ( notificationData : NotificationData) -> INSendMessageIntent {
// Create sender person
let handle = INPersonHandle (
value : String (notificationData. accountID ),
type : . unknown
)
let avatar = fetchINImage (
imageURL : notificationData. avatarURL ,
reportActionID : notificationData. reportActionID
)
let sender = INPerson (
personHandle : handle,
nameComponents : nil ,
displayName : notificationData. userName ,
image : avatar,
contactIdentifier : nil ,
customIdentifier : nil
)
// Create intent
let intent = INSendMessageIntent (
recipients : nil ,
outgoingMessageType : . outgoingMessageText ,
content : notificationData. messageText ,
speakableGroupName : notificationData. subtitle != nil ?
INSpeakableString ( spokenPhrase : notificationData. subtitle ! ) : nil ,
conversationIdentifier : String (notificationData. reportID ),
serviceName : nil ,
sender : sender,
attachments : nil
)
return intent
}
}
Payload Parsing
func parsePayload ( notificationContent : UNMutableNotificationContent) throws -> NotificationData {
guard let rawPayload = notificationContent.userInfo[ "payload" ] else {
throw ExpError. runtimeError ( "payload missing" )
}
let payload = try processPayload ( rawPayload : rawPayload)
guard let reportID = payload[ "reportID" ] as? Int64 ,
let reportActionID = payload[ "reportActionID" ] as? String ,
let onyxData = payload[ "onyxData" ] as? NSArray else {
throw ExpError. runtimeError ( "Required fields missing" )
}
// Extract report action data from onyxData[1]
guard let reportActionOnyxUpdate = onyxData[ 1 ] as? NSDictionary,
let reportActionCollection = reportActionOnyxUpdate[ "value" ] as? NSDictionary,
let reportAction = reportActionCollection[reportActionID] as? NSDictionary else {
throw ExpError. runtimeError ( "Report action data missing" )
}
guard let avatarURL = reportAction[ "avatar" ] as? String ,
let accountID = reportAction[ "actorAccountID" ] as? Int ,
let person = reportAction[ "person" ] as? NSArray,
let personObject = person[ 0 ] as? NSDictionary,
let userName = personObject[ "text" ] as? String else {
throw ExpError. runtimeError ( "Actor data missing" )
}
return NotificationData (
reportID : reportID,
reportActionID : reportActionID,
avatarURL : avatarURL,
accountID : accountID,
userName : userName,
title : notificationContent. title ,
messageText : notificationContent. body ,
subtitle : payload[ "subtitle" ] as? String
)
}
Testing iOS Notifications
To test with Staging/Production API during development:
HybridApp : Edit Mobile-Expensify/iOS/AirshipConfig/Debug/AirshipConfig.plist
< key > inProduction </ key >
< true />
Standalone : Replace development keys in ios/AirshipConfig.plist with production values.
Android Notifications
Configuration
Airship configuration in android/app/src/main/assets/airshipconfig.properties:
android/app/src/main/assets/airshipconfig.properties
# Development config
developmentAppKey = YOUR_DEV_KEY
developmentAppSecret = YOUR_DEV_SECRET
# Production config
productionAppKey = YOUR_PROD_KEY
productionAppSecret = YOUR_PROD_SECRET
# Environment
inProduction = false
# FCM sender ID
fcmSenderId = YOUR_FCM_SENDER_ID
Custom Notification Provider
android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java
import com.urbanairship.push.notifications.NotificationProvider;
public class CustomNotificationProvider extends NotificationProvider {
public CustomNotificationProvider (@ NonNull Context context , @ NonNull AirshipConfigOptions configOptions ) {
super (context, configOptions);
}
@ Override
protected NotificationCompat . Builder onExtendBuilder (
@ NonNull Context context ,
@ NonNull NotificationCompat . Builder builder ,
@ NonNull NotificationArguments arguments
) {
// Customize notification appearance
builder . setColor ( Color . parseColor ( "#03D47C" ));
builder . setPriority ( NotificationCompat . PRIORITY_HIGH );
builder . setCategory ( NotificationCompat . CATEGORY_MESSAGE );
// Custom sound
Uri soundUri = Uri . parse ( "android.resource://" +
context . getPackageName () + "/" + R . raw . receive );
builder . setSound (soundUri);
return builder;
}
}
Payload Handling
android/app/src/main/java/com/expensify/chat/customairshipextender/PayloadHandler.kt
import com.urbanairship.push.PushMessage
import org.json.JSONObject
class PayloadHandler {
fun handleNotification (message: PushMessage ) {
val payload = message.extras. getString ( "payload" )
val json = JSONObject (payload)
val reportID = json. getLong ( "reportID" )
val reportActionID = json. getString ( "reportActionID" )
val onyxData = json. getJSONArray ( "onyxData" )
// Process onyx data
processOnyxData (onyxData)
// Navigate to report when tapped
val intent = Intent (context, MainActivity:: class .java). apply {
putExtra ( "reportID" , reportID)
putExtra ( "reportActionID" , reportActionID)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
PendingIntent. getActivity (context, 0 , intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
}
Testing Android Notifications
To test with Staging/Production:
HybridApp : Edit Mobile-Expensify/Android/assets/airshipconfig.properties
Standalone : Copy production config to development:
cp android/app/src/main/assets/airshipconfig.properties \
android/app/src/development/assets/airshipconfig.properties
React Native Integration
Initialization
src/libs/Notification/PushNotification/index.native.ts
import Airship , { EventType , PermissionStatus } from '@ua/react-native-airship' ;
import ForegroundNotifications from './ForegroundNotifications' ;
/**
* Initialize push notifications
* Must be called from headless JS for Android background notifications
*/
const init : Init = () => {
// Setup event listeners
Airship . addListener (
EventType . PushReceived ,
( notification ) => {
pushNotificationEventCallback ( EventType . PushReceived , notification . pushPayload );
}
);
Airship . addListener (
EventType . NotificationResponse ,
( event ) => {
pushNotificationEventCallback ( EventType . NotificationResponse , event . pushPayload );
}
);
ForegroundNotifications . configureForegroundNotifications ();
};
export default { init } ;
Device Registration
const register : Register = ( notificationID ) => {
Airship . contact . getNamedUserId (). then (( userID ) => {
// Skip if already registered
if ( ! CONFIG . IS_HYBRID_APP && userID === notificationID . toString ()) {
return ;
}
// Request permissions (iOS prompts user)
Airship . push . getNotificationStatus (). then (({ notificationPermissionStatus }) => {
if ( notificationPermissionStatus !== PermissionStatus . NotDetermined ) {
return ;
}
Airship . push . enableUserNotifications (). then (( isEnabled ) => {
if ( ! isEnabled ) {
Log . info ( '[PushNotification] User disabled notifications' );
}
});
});
// Register device with Airship
Log . info ( `[PushNotification] Subscribing to notifications` );
Airship . contact . identify ( notificationID . toString ());
});
};
Deregistration
const deregister : Deregister = () => {
Log . info ( '[PushNotification] Unsubscribing from notifications' );
Airship . contact . reset ();
Airship . removeAllListeners ( EventType . PushReceived );
Airship . removeAllListeners ( EventType . NotificationResponse );
ForegroundNotifications . disableForegroundNotifications ();
ShortcutManager . removeAllDynamicShortcuts ();
};
Event Handling
import NotificationType from './NotificationType' ;
import parsePushNotificationPayload from './parsePushNotificationPayload' ;
type NotificationEventHandler < T > = ( data : NotificationDataMap [ T ]) => Promise < void >;
const notificationEventActionMap : NotificationEventActionMap = {};
function pushNotificationEventCallback ( eventType : EventType , notification : PushPayload ) {
const actionMap = notificationEventActionMap [ eventType ] ?? {};
const data = parsePushNotificationPayload ( notification . extras . payload );
if ( ! data || ! data . type ) {
Log . warn ( '[PushNotification] Invalid notification payload' );
return ;
}
const action = actionMap [ data . type ];
if ( ! action ) {
Log . warn ( '[PushNotification] No callback for notification type:' , data . type );
return ;
}
// Execute callback (must return promise for Android background)
return action ( data );
}
Binding Callbacks
// Bind callback for when notification is received
PushNotification . onReceived ( NotificationType . REPORT_COMMENT , async ( data ) => {
// Update badge count, show local notification, etc.
Log . info ( '[PushNotification] Report comment received:' , data . reportID );
// Update Onyx with new data
applyOnyxUpdatesFromNotification ( data . onyxData );
});
// Bind callback for when notification is tapped
PushNotification . onSelected ( NotificationType . REPORT_COMMENT , async ( data ) => {
// Navigate to the report
Log . info ( '[PushNotification] Opening report:' , data . reportID );
Navigation . navigate ( ROUTES . REPORT_WITH_ID . getRoute ( data . reportID ));
});
Notification Types
src/libs/Notification/PushNotification/NotificationType.ts
const NotificationType = {
REPORT_COMMENT: 'reportComment' ,
MODIFIED_EXPENSE: 'modifiedExpense' ,
TASK_REASSIGNED: 'taskReassigned' ,
TASK_COMPLETED: 'taskCompleted' ,
TASK_REOPENED: 'taskReopened' ,
MONEY_REQUEST: 'moneyRequest' ,
TRIP_UPDATE: 'tripUpdate' ,
} as const ;
export default NotificationType ;
Payload Structure
interface NotificationPayload {
type : keyof typeof NotificationType ;
reportID : string ;
reportActionID ?: string ;
// Onyx updates to apply
onyxData ?: OnyxUpdate [];
// Display text
title : string ;
message : string ;
subtitle ?: string ;
// User info
actorAccountID ?: number ;
avatar ?: string ;
}
Foreground Notifications
iOS Foreground Display
src/libs/Notification/PushNotification/ForegroundNotifications/index.ios.ts
import Airship from '@ua/react-native-airship' ;
function configureForegroundNotifications () {
// Show notifications even when app is in foreground
Airship . push . iOS . setForegroundPresentationOptions ([
Airship . push . iOS . ForegroundPresentationOption . Sound ,
Airship . push . iOS . ForegroundPresentationOption . Badge ,
Airship . push . iOS . ForegroundPresentationOption . Banner ,
Airship . push . iOS . ForegroundPresentationOption . List ,
]);
}
Android Foreground Display
src/libs/Notification/PushNotification/ForegroundNotifications/index.android.ts
import Airship from '@ua/react-native-airship' ;
function configureForegroundNotifications () {
// Android always shows notifications by default
// Can customize in CustomNotificationProvider
}
Badge Management
import Airship from '@ua/react-native-airship' ;
// Set badge count
function setBadgeCount ( count : number ) {
Airship . push . iOS . setBadgeNumber ( count );
}
// Clear badge
function clearBadge () {
Airship . push . iOS . setBadgeNumber ( 0 );
}
// Reset on app open
function resetBadgeOnOpen () {
if ( Platform . OS === 'ios' ) {
Airship . push . iOS . setBadgeNumber ( 0 );
}
}
Notification Permissions
Checking Permission Status
import Airship , { PermissionStatus } from '@ua/react-native-airship' ;
async function checkNotificationPermissions () {
const status = await Airship . push . getNotificationStatus ();
switch ( status . notificationPermissionStatus ) {
case PermissionStatus . Granted :
return 'granted' ;
case PermissionStatus . Denied :
return 'denied' ;
case PermissionStatus . NotDetermined :
return 'not_determined' ;
default :
return 'unknown' ;
}
}
Requesting Permissions
async function requestNotificationPermissions () {
const isEnabled = await Airship . push . enableUserNotifications ();
if ( isEnabled ) {
Log . info ( '[PushNotification] User granted notification permissions' );
} else {
Log . info ( '[PushNotification] User denied notification permissions' );
// Guide user to settings
Alert . alert (
'Enable Notifications' ,
'To receive updates, enable notifications in Settings' ,
[
{ text: 'Cancel' , style: 'cancel' },
{ text: 'Settings' , onPress : () => Linking . openSettings ()},
]
);
}
return isEnabled ;
}
Silent Push Notifications
For background data sync:
// Silent push - no notification shown, just data sync
PushNotification . onReceived ( NotificationType . SILENT_SYNC , async ( data ) => {
Log . info ( '[PushNotification] Silent push received, syncing data' );
// Apply Onyx updates in background
if ( data . onyxData ) {
await applyOnyxUpdates ( data . onyxData );
}
// No notification displayed to user
});
Notification Actions
Quick Actions (iOS)
// Define notification categories with actions
let replyAction = UNNotificationAction (
identifier : "REPLY_ACTION" ,
title : "Reply" ,
options : [. foreground ]
)
let markReadAction = UNNotificationAction (
identifier : "MARK_READ_ACTION" ,
title : "Mark as Read" ,
options : []
)
let category = UNNotificationCategory (
identifier : "MESSAGE_CATEGORY" ,
actions : [replyAction, markReadAction],
intentIdentifiers : [],
options : []
)
UNUserNotificationCenter. current (). setNotificationCategories ([category])
Handling Actions
Airship . addListener ( EventType . NotificationResponse , ( event ) => {
const actionID = event . actionId ;
switch ( actionID ) {
case 'REPLY_ACTION' :
// Open report for reply
Navigation . navigate ( ROUTES . REPORT_WITH_ID . getRoute ( event . reportID ));
break ;
case 'MARK_READ_ACTION' :
// Mark as read without opening
markReportAsRead ( event . reportID );
break ;
}
});
Troubleshooting
Not receiving notifications
Check notification permissions:
const status = await Airship . push . getNotificationStatus ();
console . log ( 'Permission:' , status . notificationPermissionStatus );
Verify device is registered:
const channelID = await Airship . channel . getChannelId ();
console . log ( 'Channel ID:' , channelID );
Check Airship configuration:
iOS: ios/AirshipConfig.plist
Android: android/app/src/main/assets/airshipconfig.properties
Notifications not showing in foreground
iOS: Check foreground presentation options: Airship . push . iOS . setForegroundPresentationOptions ([
Airship . push . iOS . ForegroundPresentationOption . Banner ,
Airship . push . iOS . ForegroundPresentationOption . Sound ,
]);
Android: Verify channel importance is not NONE
Notification tap not opening app
Check deep link handling: // Ensure navigation is ready
Airship . addListener ( EventType . NotificationResponse , ( event ) => {
// Wait for navigation to be ready
navigationRef . current ?. navigate ( ... );
});
// Clear badge explicitly
Airship . push . iOS . setBadgeNumber ( 0 );
// Or use auto badge (decrements on notification tap)
Airship . push . iOS . setAutobadgeEnabled ( true );
Best Practices
Request Permissions Contextually
Ask for notification permissions when the value is clear to the user
Handle Permission Denial Gracefully
Provide alternative ways to stay informed (email, in-app)
Use Silent Push for Data Sync
Don’t disturb users for background updates
Implement Deep Linking
Navigate directly to relevant content when notification is tapped
Test on Real Devices
Notifications behave differently on simulators
Monitor Notification Metrics
Track delivery, open rates, and engagement in Airship dashboard
Resources
Airship iOS Docs Official Airship iOS SDK documentation
Airship Android Docs Official Airship Android SDK documentation
APNs Overview Apple’s Push Notification documentation
FCM Documentation Firebase Cloud Messaging guide
Next Steps
iOS Setup Complete iOS platform setup
Android Setup Complete Android platform setup
Offline Mode Offline notification handling
Receipt Scanning Mobile receipt capture