Push Notification Design
Push Notification Design
Project: {{PROJECT_NAME}} Version: {{VERSION}} Date: {{DATE}} Author: {{AUTHOR}} Status: Draft | In Review | Approved Reviewers: {{REVIEWERS}}
Document History
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.1 | {{DATE}} | {{AUTHOR}} | Initial draft |
1. Architecture Overview
sequenceDiagram
participant Backend
participant NotifService as Notification Service\n(OneSignal / Firebase)
participant APNs as APNs (iOS)
participant FCM as FCM (Android)
participant Device
Backend->>NotifService: POST /notifications\n{userId, type, data}
NotifService->>APNs: Push payload (iOS users)
NotifService->>FCM: Push payload (Android users)
APNs-->>Device: Deliver notification (iOS)
FCM-->>Device: Deliver notification (Android)
Device->>Backend: Delivery receipt (optional)
Device->>Backend: Open/click event (analytics)
Push service: {{OneSignal | Firebase Cloud Messaging (unified) | AWS SNS | Custom}}
2. Provider Setup
2.1 APNs (iOS)
| Property | Value |
|---|---|
| Auth method | {{APNs Auth Key (.p8) — preferred over cert}} |
| Key ID | {{KEY_ID — from Apple Developer Portal}} |
| Team ID | {{TEAM_ID}} |
| Bundle ID | {{com.company.app}} |
| Environment | Dev: Sandbox |
| Key location | {{Vault reference — never commit}} |
Capabilities required in Xcode:
- Push Notifications
- Background Modes → Remote notifications
{{[x] Background Modes → Background fetch}}(if using background sync)
2.2 FCM (Android)
| Property | Value |
|---|---|
| Project ID | {{firebase-project-id}} |
| Server key | {{Vault reference}} |
| Sender ID | {{SENDER_ID}} |
| google-services.json | android/app/google-services.json (gitignored — CI injected) |
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/services/notifications.ts — abstraction layer
export async function registerForPushNotifications(): Promise<string | null> {
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') return null;
const token = await Notifications.getExpoPushTokenAsync({
projectId: Constants.expoConfig?.extra?.eas?.projectId,
});
// Register token with backend
await api.post('/users/push-token', { token: token.data });
return token.data;
}
3. Notification Types & Channels
3.1 Transactional Notifications
| Type | Trigger | Priority | Sound | Badge |
|---|---|---|---|---|
order.confirmed |
Order placed | High | Default | +1 |
payment.received |
Payment processed | High | Default | +1 |
message.received |
New chat message | High | Custom | +1 |
account.security |
Password changed, new login | Critical | Default | +1 |
{{TYPE}} |
{{TRIGGER}} |
{{High/Normal/Low}} |
{{Default/Custom/None}} |
{{+1/None}} |
3.2 Marketing / Engagement Notifications
| Type | Description | Frequency Cap | Opt-out Channel |
|---|---|---|---|
promo.offer |
Discount or limited-time offer | Max 2/week | Marketing channel |
feature.announce |
New feature announcement | Max 1/month | Marketing channel |
re_engagement |
Win-back inactive users | Max 1/week | Marketing channel |
3.3 Android Notification Channels
// Create channels on app startup (Android 8+ / API 26+)
await Notifications.setNotificationChannelAsync('transactional', {
name: 'Orders & Payments',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#FF231F7C',
sound: 'default',
});
await Notifications.setNotificationChannelAsync('messages', {
name: 'Messages',
importance: Notifications.AndroidImportance.HIGH,
sound: 'message.wav',
});
await Notifications.setNotificationChannelAsync('marketing', {
name: 'Promotions & Updates',
importance: Notifications.AndroidImportance.DEFAULT,
sound: null,
});
4. Payload Format & Schema
4.1 Data Payload vs Notification Payload
| Type | Shown by OS? | Customizable | Use when |
|---|---|---|---|
| Notification payload | Yes (auto) | Limited | Simple alerts |
| Data payload | No | Full control | App handles display |
| Combined | Yes + data | Full | Standard approach |
Recommended approach: Send both — notification payload for guaranteed delivery, data payload for app logic.
4.2 Standard Payload Schema
{
"notification": {
"title": "{{Notification title}}",
"body": "{{Notification body text}}",
"image": "{{optional image URL}}"
},
"data": {
"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,
"content-available": 1
}
}
},
"android": {
"channelId": "{{transactional | messages | marketing}}",
"priority": "high"
}
}
4.3 Localization
- All notification text must be localized
- Approach:
{{Backend sends localized text based on user's locale preference | App localizes using data payload keys}} - Fallback locale:
en
5. Deep Linking Strategy
5.1 URL Scheme Configuration
URL scheme: {{appname://}}
Universal links (iOS): {{https://app.domain.com}}
App links (Android): {{https://app.domain.com}}
5.2 Deep Link Routing
| Notification Type | Deep Link | Screen |
|---|---|---|
order.confirmed |
appname://orders/{{orderId}} |
Order Detail |
message.received |
appname://chats/{{chatId}} |
Chat Screen |
payment.received |
appname://wallet |
Wallet Screen |
promo.offer |
appname://offers/{{offerId}} |
Offer Detail |
5.3 Navigation on Notification Tap
// App foreground — notification tap handler
Notifications.addNotificationResponseReceivedListener((response) => {
const { notificationType, deepLinkPath } = response.notification.request.content.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);
}
});
}, []);
6. Opt-In / Opt-Out Flow
Permission request timing: {{After onboarding, on first meaningful action — NOT on app launch}}
Permission flow:
App Launch
↓
Onboarding Complete
↓
First Relevant Action (e.g., order placed)
↓
Pre-prompt Modal (custom UI — explain value)
"Get notified when your order is ready"
[Allow] [Not now]
↓ (Allow)
OS Permission Dialog
↓
Register token with backend
Soft prompt: Always show custom pre-prompt before OS dialog. Users who dismiss OS dialog permanently cannot be re-asked — use pre-prompt to qualify intent.
7. Notification Preferences Per User
interface NotificationPreferences {
pushEnabled: boolean; // Master toggle
channels: {
transactional: boolean; // Orders, payments — default: true
messages: boolean; // Chat messages — default: true
marketing: boolean; // Promotions — default: false
security: boolean; // Security alerts — always true, non-toggleable
};
quietHours: {
enabled: boolean;
start: string; // "22:00" HH:mm
end: string; // "08:00" HH:mm
timezone: string; // "Europe/Oslo"
};
}
API endpoint: PUT /users/notification-preferences
Sync: Preferences stored on server — synced on login and app foreground.
8. Rate Limiting & Throttling
| Category | Limit | Window | Behavior at limit |
|---|---|---|---|
| Transactional | Unlimited | — | Always delivered |
| Messages | 100 | 1 hour | Batch: "X new messages" |
| Marketing | 2 | 7 days | Drop excess, no queue |
| Security | Unlimited | — | Always delivered |
Backend enforcement: Rate limit checked before sending to push service. Excess notifications are logged but not sent.
9. Analytics & Tracking
| Event | Tracked How | Data Points |
|---|---|---|
| Sent | Backend log | notificationType, userId, timestamp |
| Delivered | Push service receipt | notificationType, userId, deliveredAt |
| Opened | App handler | notificationType, userId, openedAt, timeToOpen |
| Dismissed | {{Platform support}} |
{{Android only — iOS limited}} |
| Action tap | App handler | notificationType, actionId, userId |
Funnel 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.
10. Testing Strategy
| Test Type | Method | Environment |
|---|---|---|
| Payload validation | Unit test push service | Dev |
| Delivery smoke test | Send test notification via dashboard | Staging |
| Deep link routing | E2E test: tap notification → verify screen | Staging |
| Opt-in flow | E2E test: full permission flow | Staging / device |
| Rate limiting | Integration test: exceed limit → verify drop | Staging |
Test push tool: {{OneSignal Dashboard | Expo push notification tool | Firebase console}}
TODO: Create Maestro test flows for notification tap → deep link scenarios.
Approval
| Role | Name | Date | Signature |
|---|---|---|---|
| Author | |||
| Mobile Lead | |||
| Backend Lead | |||
| Product Owner |
No comments to display
No comments to display