Expo Push Notifications
Status: Reference
Status: Planned (Phase 2) Part of: External APIs index
Role
Notify club members and pickup game participants of time-sensitive events:
| Trigger | Recipient | Message example |
|---|---|---|
| RSVP confirmed | Member | "✅ [3/15 복식] 참가 확정됐어요!" |
| Waitlist promoted | Waitlisted member | "🎾 자리가 났어요! 지금 확인하세요." |
| Match scheduled | Session RSVP'd members | "📋 매치 대진이 나왔어요. 확인해보세요." |
| Score entry needed | Match player | "입력 대기 중인 경기 결과가 있어요." |
| Dues reminder | Member with balance | "이달 회비 {n}원이 아직 미납이에요." |
| Session reminder | RSVP'd members | "내일 {time} 경기 잊지 마세요! 🎾" |
Current state
The NotificationServicePort is stubbed with no-ops in src/registry.ts:
const notificationStub: NotificationServicePort = {
async requestPermission() {
return 'undetermined';
},
async getExpoPushToken() {
return 'mock-token';
},
async scheduleLocal() {
return 'mock-id';
},
async cancelScheduled() {
/* no-op */
},
onNotificationReceived() {
return () => {};
},
};All notification calls succeed silently during Phase 1 without sending anything.
Architecture
Client app Supabase Edge Function
───────────────────────────── ──────────────────────────────
1. requestPermission() → (once on first launch)
2. getExpoPushToken() → store in profiles.push_token
(via profiles.update mutation)
Event occurs (RSVP, score, etc.)
↓
DB trigger / application code
↓
supabase/functions/notify/index.ts
↓
POST https://exp.host/--/api/v2/push/send
{ to: [push_token], title, body, data }The Expo Push API acts as a single gateway to both FCM (Android) and APNs (iOS), so TwoMore only needs one server-side integration.
SDK
npx expo install expo-notificationsAlready listed in dependencies. No native config changes needed — Expo manages FCM/APNs registration automatically during EAS Build when credentials are configured.
Credentials
| Credential | Platform | Where to configure |
|---|---|---|
| FCM Server Key (v1) | Android | Firebase console → Cloud Messaging |
| APNs Auth Key (.p8) | iOS | Apple Developer → Certificates, Identifiers & Profiles |
| Both | eas credentials --platform android/ios |
EAS Build uploads credentials to Expo servers. The Edge Function uses the Expo Push API (not FCM/APNs directly), so neither FCM nor APNs credentials are needed in app code.
Quotas & cost
| Service | Cost |
|---|---|
| Expo Push API | Free, no published limits |
| FCM (Google) | Free |
| APNs (Apple) | Free |
No billing concern for Phase 2.
Client implementation checklist
- [ ] Add
profiles.push_token TEXTcolumn in next migration - [ ] Create
packages/app/src/adapters/expo/notification.expo.tsimplementingNotificationServicePort - [ ] Wire real adapter in
src/registry.ts(replace the stub) - [ ] Call
requestPermission()+getExpoPushToken()on first app launch after login - [ ] Persist token to profile via
profiles.updatemutation - [ ] Handle
onNotificationReceivedto navigate to the relevant screen when app is foregrounded - [ ] Handle notification tap (app was backgrounded/killed) via
getLastNotificationResponseAsync()
Edge Function implementation checklist
- [ ] Create
supabase/functions/notify/index.ts - [ ] Set
EXPO_ACCESS_TOKENsecret viasupabase secrets set(optional — Expo Push API works without auth but token prevents quota abuse) - [ ] DB trigger or application code calls the function after relevant events
- [ ] Function fetches recipient
push_tokenfromprofilesand POSTs to Expo Push API
Expo Push API request format
// Edge Function sends this
const message = {
to: 'ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]',
sound: 'default',
title: '✅ 참가 확정',
body: '[3/15 복식] 참가 확정됐어요!',
data: {
screen: 'session',
sessionId: 'uuid-here',
},
};
const response = await fetch('https://exp.host/--/api/v2/push/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(message),
});Batch sending
// Send up to 100 messages per request
await fetch('https://exp.host/--/api/v2/push/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify([message1, message2, ...]),
});Batch when notifying all RSVP'd members (e.g. "매치 대진 공개") — one request per 100 recipients.
Error handling (Edge Function)
Expo Push API returns per-message status even on HTTP 200:
{
"data": [
{ "status": "ok", "id": "..." },
{ "status": "error", "message": "DeviceNotRegistered" },
],
}On DeviceNotRegistered: delete the stale push_token from the profile to avoid wasting calls.
Deep link navigation from notification tap
When a user taps a notification, data.screen + data.sessionId should navigate to the correct screen:
// In app root or notification handler
Notifications.addNotificationResponseReceivedListener((response) => {
const { screen, sessionId, clubId } = response.notification.request.content.data;
if (screen === 'session') router.push(routes.sessionDetail(clubId, sessionId));
if (screen === 'dues') router.push(routes.dues(clubId));
});