Skip to main content

Push Notification Design

Push Notification Design

Project: Drop — Fintech Payment App{{PROJECT_NAME}} Version: 0.1.0{{VERSION}} Date: 2026-02-23{{DATE}} Author: John (AI Director, ALAI){{AUTHOR}} Status: Draft | In Review | Approved Reviewers: Alem Bašić (CEO){{REVIEWERS}}

Document History

Version Date Author Changes
0.1 2026-02-23{{DATE}} John{{AUTHOR}} Initial draft — current state (not implemented) + Phase 2 design

1. Architecture Overview

Current state (Phase 1): Push notifications are NOT yet implemented in the mobile app. The /profile/notifications page on the web app supports toggling push/email notifications in user settings (PATCH /api/settings), and the notifications feature flag is true. The backend notification infrastructure is TBD.

Phase 2 target architecture:

sequenceDiagram
    participant Backend
    as Drop Backend\n(Hono v4)
    participant EPNNotifService as ExpoNotification Push\nNotificationService\n(OneSignal Service/ Firebase)
    participant APNs as APNs (iOS)
    participant FCM as FCM (Android)
    participant Device

    as User Device\n(Drop Mobile App)

    Note over Backend,Device: Transaction event triggers notification

    Backend->>EPN:NotifService: POST /send-push-notifications\n{to:userId, expoPushToken, title, body,type, data}
    EPN-NotifService->>APNs: Push payload (iOS users)
    EPN-NotifService->>FCM: Push payload (Android users)
    APNs-->>Device: Deliver notification (iOS)
    FCM-->>Device: Deliver notification (Android)
    Device->>Backend: OpenDelivery receipt (optional)
    Device->>Backend: Open/click event (deep link handling)analytics)

Push service: Expo{{OneSignal Push| NotificationFirebase ServiceCloud Messaging (EPN)unified) | managedAWS bySNS Expo,| handles APNs and FCM routing. No separate OneSignal/Firebase setup needed with Expo managed workflow.Custom}}


2. Provider Setup

2.1 APNs (iOS)

Property Value
Auth method {{APNs Auth Key (.p8) — managedpreferred byover Expocert}}
Key ID{{KEY_ID — from Apple Developer Portal}}
Team ID{{TEAM_ID}}
Bundle ID no.getdrop.app{{com.company.app}}
Environment Dev: Sandbox / Prod: Production
Key managementlocation Expo{{Vault managed credentialsreferencestorednever in EAScommit}}

Capabilities required in Expo app.config.ts:Xcode:

  •  Push Notifications
  •  Background Modes → Remote notifications
  • {{[x] Background Modes → Background fetch}} (if using background sync)

2.2 FCM (Android)

Expomanagedworkflow
Property Value
IntegrationProject ID Via{{firebase-project-id}}
Server key{{Vault reference}}
Sender ID{{SENDER_ID}}
google-services.json Managedandroid/app/google-services.json by EAS(gitignorednotCI committed to repo
Android permissionPOST_NOTIFICATIONS (Android 13+ / API 33+) — Expo handlesinjected)

Android Manifest additions:

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- For Android 13+ — request at runtime -->

2.3 Unified Service Configuration

SDK: {{expo-notifications | react-native-firebase | onesignal-react-native}}

// src/drop-mobile/lib/services/notifications.ts — Phaseabstraction 2

import * as Notifications from 'expo-notifications';
import Constants from 'expo-constants';layer
export async function registerForPushNotifications(): Promise<string | null> {
  // Check existing permissions
  const { status: existingStatus } = await Notifications.getPermissionsAsync();
  let finalStatus = existingStatus;

  // Request if not yet granted

  if (existingStatus !== 'granted') {
    const { status } = await Notifications.requestPermissionsAsync();
    finalStatus = status;
  }

  if (finalStatus !== 'granted') {
    console.log('Push notification permission denied');
    return null;
  }

  // Get Expo push token

  const token = await Notifications.getExpoPushTokenAsync({
    projectId: Constants.expoConfig?.extra?.eas?.projectId,
  });

  // Register token with Drop backend
  await api.post('/users/push-token', { token: token.data, platform: Platform.OSdata });

  // Configure Android notification handler
  if (Platform.OS === 'android') {
    await Notifications.setNotificationChannelAsync('transactional', {
      name: 'Betalinger og overføringer',
      importance: Notifications.AndroidImportance.HIGH,
      vibrationPattern: [0, 250, 250, 250],
      sound: 'default',
    });
    await Notifications.setNotificationChannelAsync('security', {
      name: 'Sikkerhet',
      importance: Notifications.AndroidImportance.HIGH,
    });
    await Notifications.setNotificationChannelAsync('marketing', {
      name: 'Nyheter og tilbud',
      importance: Notifications.AndroidImportance.DEFAULT,
    });
  }
  return token.data;
}

3. Notification Types & Channels

3.1 Transactional Notifications (Drop Core)

levert"
Type Trigger Priority Sound Badge Norwegian Title Example
transaction.remittance_sentorder.confirmed RemittanceOrder payment initiatedplaced High Default +1 "Penger sendt"
transaction.remittance_deliveredpayment.received RemittancePayment delivered to recipientprocessed High Default +1
"Pengermessage.received New chat messageHighCustom+1
transaction.qr_paymentaccount.security QRPassword paymentchanged, completednew login HighCritical Default +1 "Betaling fullført"
transaction.failed{{TYPE}} Payment failedHighDefault+1"Betaling mislyktes"
account.balance_update{{TRIGGER}} Balance change detected via AISP{{High/Normal/Low}} Normal{{Default/Custom/None}} NoneNone"Saldo oppdatert"{{+1/None}}

3.2 SecurityMarketing / Engagement Notifications (Always On — Cannot Disable)

TypeTriggerPriorityNorwegian Title Example
security.new_loginNew device loginCritical"Ny pålogging oppdaget"
security.bankid_consentBankID consent grantedHigh"BankID-tillatelse gitt"
security.suspicious_activityUnusual transaction patternCritical"Uvanlig aktivitet oppdaget"

3.3 Informational Notifications (User-Toggleable)

Type Description Frequency Cap Opt-out Channel
info.rate_updatepromo.offer FavorableDiscount exchangeor ratelimited-time alertoffer Max 1/day2/week InformationalMarketing channel
info.feature_updatefeature.announce New Drop feature announcement Max 1/month Marketing channel
info.new_corridorre_engagement NewWin-back remittanceinactive country addedusers Max 1/monthweek Marketing channel

3.43 Android Notification Channels

// CreatedCreate channels on app startup for (Android 8+ (/ API 26+)
constawait channels = [
  {
    id: Notifications.setNotificationChannelAsync('transactional', {
  name: 'BetalingerOrders og& overføringer'Payments',
  importance: Notifications.AndroidImportance.HIGH,
  description:vibrationPattern: [0, 250, 250, 250],
  lightColor: 'Varsler om dine betalinger og pengeoverføringer'#FF231F7C',
  sound: 'default',
vibration:});

true,await }Notifications.setNotificationChannelAsync('messages', {
  id:name: 'security'Messages',
  importance: Notifications.AndroidImportance.HIGH,
  sound: 'message.wav',
});

await Notifications.setNotificationChannelAsync('marketing', {
  name: 'Sikkerhet'Promotions & Updates',
  importance: AndroidImportance.HIGH,
    description: 'Viktige sikkerhetsvarsler — kan ikke deaktiveres',
    sound: 'default',
    vibration: true,
  },
  {
    id: 'marketing',
    name: 'Nyheter og tilbud',
    importance: Notifications.AndroidImportance.DEFAULT,
    description: 'Nyheter om Drop og nye funksjoner',
  sound: null,
vibration: false,
  },
]);

4. Payload Format & Schema

4.1 StandardData DropPayload vs Notification Payload

TypeShown by OS?CustomizableUse when
Notification payloadYes (auto)LimitedSimple alerts
Data payloadNoFull controlApp handles display
CombinedYes + dataFullStandard approach

4.2 Standard Payload Schema

{
  "to"notification": "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]",{
    "title": "Penger{{Notification sendt"title}}",
    "body": "Du{{Notification sendtebody 5text}}",
    000"image": NOK"{{optional tilimage AhmadURL}}"
  Karimi},
  i"data": Serbia"{
    "notificationType": "{{order.confirmed | message.received | etc.}}",
    "entityType": "{{order | message | user | etc.}}",
    "entityId": "{{UUID}}",
    "deepLinkPath": "{{/orders/123}}",
    "timestamp": "{{ISO_8601}}",
    "version": "1"
  },
  "apns": {
    "payload": {
      "aps": {
        "sound": "default",
        "badge": 1,
        "data"content-available": 1
      }
    }
  },
  "android": {
    "notificationType": "transaction.remittance_sent",
    "entityType": "transaction",
    "entityId": "txn_abc123def456",
    "deepLinkPath": "/(tabs)/",
    "amount": "5000",
    "currency": "NOK",
    "recipientName": "Ahmad Karimi",
    "targetCurrency": "RSD",
    "targetAmount": "58525",
    "timestamp": "2026-02-23T14:30:00Z",
    "version": "1"
  },
    "channelId": "transactional"{{transactional | messages | marketing}}",
    "priority": "high"
  }
}

4.23 Localization

  • All notification text inmust Norwegianbe Bokmål (Phase 1 — Norwegian market only)localized
  • Approach: {{Backend generatessends localized text based on user.languageuser's locale preference | App localizes using data payload keys}}
  • Fallback locale: nb
  • Phase 2: en, bs, sq when language settings are implemented

5. Deep Linking Strategy

5.1 URL Scheme Configuration

URL scheme: drop:{{appname:// Expo Linking config: app.config.ts → scheme: "drop"}} Universal links (iOS): {{https://getdrop.noapp.domain.com}} (Phase 2 — requires AASA file) App links (Android): {{https://getdrop.noapp.domain.com}} (Phase 2 — requires assetlinks.json)

Notification Type Deep Link Screen
transaction.remittance_sentorder.confirmed drop:appname://orders/{{orderId}} (tabs home) DashboardOrder Detail
transaction.remittance_deliveredmessage.received drop:appname://chats/{{chatId}} DashboardChat Screen
transaction.qr_paymentpayment.received drop:appname://wallet DashboardWallet Screen
transaction.failedpromo.offer drop:appname://sendoffers/{{offerId}} SendOffer Money
security.new_logindrop://profileProfile / Security
security.suspicious_activitydrop://profileProfile / Security
info.rate_updatedrop://sendSend MoneyDetail

5.3 Navigation on Notification Tap

// Phase 2 — notification response handler
import { useRouter } from 'expo-router';

export function useNotificationNavigation() {
  const router = useRouter();

  useEffect(() => {
    // App in foreground — notification tap const subscription =handler
Notifications.addNotificationResponseReceivedListener((response) => {
  const { notificationType, deepLinkPath } = response.notification.request.content.data ?? {};data;

  if (deepLinkPath) {
    // Use router.push for Expo Router, or navigation.navigate for React Navigation
    router.push(deepLinkPath);
  }
});

// App killed — check initial notification
useEffect(() => {
  Notifications.getLastNotificationResponseAsync().then((response) => {
    if (response?.notification.request.content.data?.deepLinkPath) {
      router.replace(response.notification.request.content.data.deepLinkPath);
    }
  });
return () => subscription.remove();
  }, []);
}

6. Opt-In / Opt-Out Flow

Permission request timing: {{After onboarding, on first successfulmeaningful transactionactionnotNOT on app launch.launch}}

Norwegian pre-prompt text:

"Vil du få varsler når pengene dine er sendt eller mottatt?" [Ja, slå på varsler] [Ikke nå]

Permission flow:

App Launch
    ↓
Onboarding Complete
    ↓
First transactionRelevant completedAction (e.g., order placed)
    ↓
Pre-prompt modalModal (custom DropUI UI)— explain value)
    "Get varselnotified nårwhen pengeneyour dineorder eris levert"ready"
    "Ja,[Allow]  slå[Not på varsler" | "Ikke nå"
    ↓ (Ja)
iOS/Android OS permission dialognow]
    ↓ (Allow)
OS Permission Dialog
    ↓
Register Expo push token with backend
POST /v1/users/push-token

Soft prompt rule:prompt: Always show custom pre-prompt before OS dialog. Users who dismiss the OS dialog permanently cannot be re-asked on iOS theuse pre-prompt qualifiesto intentqualify first.

In-app settings: /profile/notifications page (web) — toggle push/email notifications. Mobile settings: Via profile.js → Settings menu → Varsler (Phase 2).intent.


7. Notification Preferences Per User

interface NotificationPreferences {
  pushEnabled: boolean;       // Master toggle
  pushToken: string | null;       // Expo push token
  channels: {
    transactional: boolean;   // PaymentOrders, alertspayments — default: true
    messages: boolean;        // Chat messages — default: true
    marketing: boolean;       // Promotions — default: false
    security: boolean;        // Security alerts — ALWAYSalways true, non-toggleable
    marketing: boolean;           // News and offers — default: false
  };
  quietHours: {
    enabled: boolean;             // default: false
    start: string;            // "22:00" HH:mm (Europe/Oslo)
    end: string;              // "08:00" HH:mm
    timezone: string;         // "Europe/Oslo"
  };
}

API endpoint: PUT /v1/users/notification-preferences

Web sync:Sync: Preferences stored on server — synced viaon /api/settingslogin endpoints (weband app PATCH).foreground.


8. Rate Limiting & Throttling

Category Limit Window Behavior at Limitlimit
Transactional Unlimited Always delivered
Messages1001 hourBatch: "X new messages"
Marketing27 daysDrop excess, no queue
Security Unlimited Always delivered
Rate alerts124 hoursDrop excess
Marketing130 daysDrop excess

Backend enforcement: Rate limit checked before sending to Expo Pushpush service. Excess notifications are logged but not sent.


9. Analytics & Tracking

opened
Event Tracked How Data Points
Token registeredBackend loguserId (hashed), platform, timestamp
Notification sentSent Backend log notificationType, userId (hashed),userId, timestamp
NotificationDelivered Push service receiptnotificationType, userId, deliveredAt
Opened App handler notificationType, deepLinkPath,userId, openedAt, timeToOpen
Permission grantedDismissed App{{Platform eventsupport}} platform,{{Android timestamponly — iOS limited}}
PermissionAction deniedtap App eventhandler platform,notificationType, timestampactionId, userId

GDPRFunnel note:metrics:

  • Delivery rate = delivered / sent
  • Open rate = opened / delivered
  • CTR (click-through) = action taps / opened

Privacy: User IDs are hashed in analytics. No PII in event data. Push tokens are treated as personal data per GDPR.


10. Testing Strategy

Test Type Method Environment
Payload validation Unit test push payload builderDev
Token registrationIntegration test with mock APIservice Dev
Delivery smoke test Send test notification via Expo push tooldashboard Staging
Deep link routing Manual:E2E receivetest: tap notification → tap → verify screen Physical deviceStaging
Opt-in flow Manual:E2E test: full permission request flow PhysicalStaging / device
AndroidRate channelslimiting Manual:Integration test: exceed limit → verify channels in Android settingsdrop Physical deviceStaging

Test push tool: Expo{{OneSignal PushDashboard Notification Tool — send test notifications to specific| Expo push tokens.notification tool | Firebase console}}

Testing limitation:TODO: ExpoCreate GoMaestro apptest does not support push notifications — must use development build (eas build --profile development)flows for push notification testing.tap → deep link scenarios.


Approval

Role Name Date Signature
Author John (AI Director) 2026-02-23
Mobile Lead
Backend Lead
Product Owner