Skip to main content
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):
ios/AppDelegate.swift
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)
  }
}

iOS Notifications

Configuration

Airship configuration in ios/AirshipConfig.plist:
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
inProduction = true
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

  1. Check notification permissions:
    const status = await Airship.push.getNotificationStatus();
    console.log('Permission:', status.notificationPermissionStatus);
    
  2. Verify device is registered:
    const channelID = await Airship.channel.getChannelId();
    console.log('Channel ID:', channelID);
    
  3. Check Airship configuration:
    • iOS: ios/AirshipConfig.plist
    • Android: android/app/src/main/assets/airshipconfig.properties
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
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

1

Request Permissions Contextually

Ask for notification permissions when the value is clear to the user
2

Handle Permission Denial Gracefully

Provide alternative ways to stay informed (email, in-app)
3

Use Silent Push for Data Sync

Don’t disturb users for background updates
4

Implement Deep Linking

Navigate directly to relevant content when notification is tapped
5

Test on Real Devices

Notifications behave differently on simulators
6

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

Build docs developers (and LLMs) love