Skip to content

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:

TriggerRecipientMessage example
RSVP confirmedMember"✅ [3/15 복식] 참가 확정됐어요!"
Waitlist promotedWaitlisted member"🎾 자리가 났어요! 지금 확인하세요."
Match scheduledSession RSVP'd members"📋 매치 대진이 나왔어요. 확인해보세요."
Score entry neededMatch player"입력 대기 중인 경기 결과가 있어요."
Dues reminderMember with balance"이달 회비 {n}원이 아직 미납이에요."
Session reminderRSVP'd members"내일 {time} 경기 잊지 마세요! 🎾"

Current state

The NotificationServicePort is stubbed with no-ops in src/registry.ts:

typescript
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

bash
npx expo install expo-notifications

Already listed in dependencies. No native config changes needed — Expo manages FCM/APNs registration automatically during EAS Build when credentials are configured.


Credentials

CredentialPlatformWhere to configure
FCM Server Key (v1)AndroidFirebase console → Cloud Messaging
APNs Auth Key (.p8)iOSApple Developer → Certificates, Identifiers & Profiles
Botheas 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

ServiceCost
Expo Push APIFree, no published limits
FCM (Google)Free
APNs (Apple)Free

No billing concern for Phase 2.


Client implementation checklist

  • [ ] Add profiles.push_token TEXT column in next migration
  • [ ] Create packages/app/src/adapters/expo/notification.expo.ts implementing NotificationServicePort
  • [ ] 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.update mutation
  • [ ] Handle onNotificationReceived to 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_TOKEN secret via supabase 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_token from profiles and POSTs to Expo Push API

Expo Push API request format

typescript
// 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

typescript
// 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:

jsonc
{
  "data": [
    { "status": "ok", "id": "..." },
    { "status": "error", "message": "DeviceNotRegistered" },
  ],
}

On DeviceNotRegistered: delete the stale push_token from the profile to avoid wasting calls.


When a user taps a notification, data.screen + data.sessionId should navigate to the correct screen:

typescript
// 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));
});

Markdown remains the source of truth. Run yarn docs:check before handoff.