Skip to main content
Local notifications work out of the box. Remote push requires APNs (iOS) and FCM (Android) credentials.

iOS Setup (APNs)

EAS Build handles APNs certificates automatically. Just ensure:
  1. Push Notifications capability is enabled in Apple Developer Portal
  2. app.json includes:
    {
      "ios": {
        "infoPlist": {
          "UIBackgroundModes": ["remote-notification"]
        }
      }
    }
    
Build with eas build --platform ios.

Android Setup (FCM)

  1. Create a Firebase project at console.firebase.google.com
  2. Add your Android app (use package name from app.json)
  3. Download google-services.json to apps/app/
  4. For server-side sending, set up FCM credentials in your backend: Supabase: Upload your Firebase service account JSON in the Supabase Dashboard under Integrations → Firebase. Convex: Add your Firebase service account JSON as an environment variable in the Convex Dashboard.
The legacy FCM Server Key is deprecated. Firebase now uses HTTP v1 API with OAuth 2.0 service account authentication. All server credentials should be configured in your backend dashboard, not in client-side environment variables.
See Expo Push Notifications for detailed setup.

Usage

Request Permission

import { useNotificationStore } from '@/stores/notificationStore'

function EnableNotifications() {
  const { requestPermission, permissionStatus } = useNotificationStore()

  const handleEnable = async () => {
    const granted = await requestPermission()
    // If denied, an alert prompts user to open Settings
  }

  return permissionStatus === 'undetermined' && (
    <Button onPress={handleEnable} title="Enable Notifications" />
  )
}

Toggle Push Notifications

const { togglePush, isPushEnabled } = useNotificationStore()

// Toggle on/off - handles permission request automatically
await togglePush(user?.id) // Pass userId to sync preference to backend

Schedule Local Notification

import { scheduleNotification } from '@/services/notifications'

await scheduleNotification({
  title: 'Reminder',
  body: 'Time to check your progress!',
  data: { screen: 'Profile' },
  trigger: { seconds: 3600 },
})

Send Remote Notification (Backend)

Get the push token from your app:
const { pushToken } = useNotificationStore()
// Send pushToken to your backend
Send from Node.js backend:
import { Expo } from 'expo-server-sdk'

const expo = new Expo()
await expo.sendPushNotificationsAsync([{
  to: pushToken,
  title: 'New Message',
  body: 'You have a new message',
  data: { screen: 'Messages' },
}])
See Expo Push Service.

Handle Notification Taps

The notification store handles this automatically via initialize(). For custom handling:
import { addNotificationResponseReceivedListener } from '@/services/notifications'
import { useNavigation } from '@react-navigation/native'

useEffect(() => {
  const subscription = addNotificationResponseReceivedListener(response => {
    const { screen } = response.notification.request.content.data
    if (screen) navigation.navigate(screen)
  })
  return () => subscription.remove()
}, [])

Automatic Initialization

The notification store initializes automatically on app startup:
  • Requests permission status
  • Registers for push tokens (on physical devices)
  • Sets up notification listeners
  • Handles token refresh when tokens are invalidated
  • Configures Android notification channels
No manual setup required - just use the store.

API Reference

Notification Store

const {
  permissionStatus,     // 'granted' | 'denied' | 'undetermined' | 'loading'
  pushToken,            // Expo push token for remote notifications
  isPushEnabled,        // User preference toggle
  notifications,        // Array of received notifications (last 50)
  unreadCount,          // Number of unread notifications

  // Actions
  initialize,           // Called automatically on app start
  cleanup,              // Called automatically on app unmount
  requestPermission,    // Request OS permission
  togglePush,           // Toggle notifications on/off
  scheduleNotification, // Schedule local notification
  cancelAllNotifications,
  setBadgeCount,
  markAsRead,
  markAllAsRead,
} = useNotificationStore()

Notification Service

import {
  requestPermission,
  registerForPushNotifications,
  scheduleNotification,
  cancelNotification,
  addNotificationReceivedListener,
  addNotificationResponseReceivedListener,
  addPushTokenListener,
  showPermissionDeniedAlert,
  isPhysicalDevice,
} from '@/services/notifications'

Features

Physical Device Detection

Push tokens only work on physical devices. The service automatically detects emulators/simulators and returns mock tokens for development.
import { isPhysicalDevice } from '@/services/notifications'

if (!isPhysicalDevice()) {
  console.log('Running on emulator - using mock push token')
}

Token Refresh Handling

Push tokens can be invalidated by the OS. The store automatically listens for token changes and re-registers when needed.

Android Notification Channels

Android 8.0+ requires notification channels. Shipnative configures a default channel automatically with:
  • Maximum importance (ensures notifications appear prominently)
  • Vibration pattern
  • Sound enabled
  • Badge support
  • Show on lockscreen
Channels are created automatically on first notification. No manual configuration needed.

Permission Denied Alerts

When users deny permission, a helpful alert offers to open Settings:
import { showPermissionDeniedAlert } from '@/services/notifications'

// Called automatically by togglePush() and requestPermission()
// Or call manually:
showPermissionDeniedAlert()
The alert includes:
  • Clear explanation of what permission enables
  • “Open Settings” button (deep links to app settings)
  • “Maybe Later” option
  • Platform-specific instructions (iOS vs Android)

Backend Synchronization

Push token is automatically synced to your backend:
// In services/notifications.ts - automatically called
const { pushToken } = useNotificationStore()

// Token is synced to:
// - Supabase: push_tokens table with user association
// - Convex: user.pushToken field
// - Or custom backend via env.backendUrl/push-tokens

// Backend can use token to send notifications
await fetch(`${backendUrl}/notifications/send`, {
  method: 'POST',
  body: JSON.stringify({
    token: pushToken,
    title: 'New Message',
    body: 'You have a new message!',
  })
})

Notification Badge Management

Control app badge counts on iOS and Android:
const { setBadgeCount, unreadCount } = useNotificationStore()

// Set badge to unread count
setBadgeCount(unreadCount)

// Clear badge
setBadgeCount(0)

// Automatically cleared when marking notifications as read
markAllAsRead() // Also clears badge

Analytics Integration

Notification events are automatically tracked with PostHog:
// Automatically tracked:
// - notification_permission_requested
// - notification_permission_granted
// - notification_permission_denied
// - notification_received (foreground)
// - notification_tapped (user interaction)
// - notification_scheduled (local)

// Events include metadata:
{
  title: 'Notification title',
  hasData: true,
  screen: 'HomeScreen', // If data includes screen
  platform: 'ios' | 'android' | 'web'
}

Limitations

  • Web: Push tokens return null. Remote push via Expo not supported on web.
  • Emulators: Remote push doesn’t work. Mock tokens returned for development.

Troubleshooting

Notifications not appearing?
  • Check permissionStatus is 'granted'
  • For iOS remote: use physical device, verify APNs setup
  • For Android: verify google-services.json is in place
Push token is null?
  • Expected on web platform
  • On mobile: check permission was granted first
  • On emulator: mock token is returned (check logs)
Permission denied?
  • The store shows an alert prompting users to open Settings
  • Check permissionStatus for current state
Token changed unexpectedly?
  • Normal behavior - tokens can be invalidated by the OS
  • The store handles this automatically via addPushTokenListener

Next Steps