Skip to content

Push Notifications

Klaxon pushes through two channels, both backed by Firebase:

  • FCM — Android (GCM) and iOS (APNs) for the Expo mobile app
  • Web Push — browser-registered subscriptions for the web dashboard when the tab is backgrounded

Both share the same notification_queue outbox table and the same worker; they differ only in the delivery adapter.

How it works

  1. Client (mobile or browser) requests notification permission and receives a device/subscription token from Firebase.
  2. Client registers the token: POST /api/push/register with platform: "fcm" | "webpush".
  3. When an item is created, the server enqueues a push notification row in notification_queue for every subscriber in the org that hasn't opted out (per-channel + per-level rules, quiet hours, etc.).
  4. The background worker picks up queue entries (FOR UPDATE SKIP LOCKED) and calls the right adapter: FCM v1 HTTP API for mobile, Web Push for browsers.
  5. Firebase delivers to the device.

Device registration

bash
# Mobile (FCM)
curl -X POST http://localhost:3000/api/push/register \
  -H "Authorization: Bearer TOKEN" \
  -d '{"device_token": "fcm-token-here", "platform": "fcm"}'

# Browser (Web Push)
curl -X POST http://localhost:3000/api/push/register \
  -H "Authorization: Bearer TOKEN" \
  -d '{
    "device_token": "<web-push-subscription-json>",
    "platform": "webpush"
  }'

# Unregister (either platform)
curl -X POST http://localhost:3000/api/push/unregister \
  -H "Authorization: Bearer TOKEN" \
  -d '{"device_token": "token-or-subscription"}'

The web dashboard handles registration automatically via the Firebase Web SDK + a service worker (/firebase-messaging-sw.js). Browsers that don't grant notification permission fall back to WebSocket-only real-time updates.

Delivery Guarantees

The notification queue uses a persistent outbox pattern in PostgreSQL:

  • At-least-once delivery: notifications survive server restarts
  • Exponential backoff: failed deliveries retry with 2^attempts second delays
  • Max 5 attempts: after 5 failures, the notification is marked as permanently failed
  • Concurrent-safe: workers use FOR UPDATE SKIP LOCKED for safe multi-replica processing

Per-User Quiet Hours

Users can configure quiet hours to suppress push during specific times:

SettingDescription
quiet_hours_enabledEnable/disable
quiet_hours_startStart time (HH:MM, e.g. "22:00")
quiet_hours_endEnd time (e.g. "07:00") — handles midnight wrapping
quiet_hours_allow_levelsComma-separated levels that bypass quiet hours (e.g. "error")

Configuration

Env varPurpose
FCM_SERVICE_ACCOUNT_JSONFull contents of a Firebase service-account key (JSON). Used to mint OAuth2 tokens for the FCM v1 API.
FIREBASE_VAPID_KEYVAPID public key from Firebase Console → Cloud Messaging → Web Push certificates. Used by the browser SDK. Public — safe to ship to the client.
FIREBASE_API_KEY, FIREBASE_PROJECT_ID, FIREBASE_APP_ID, FIREBASE_MESSAGING_SENDER_IDStandard Firebase Web SDK config. All public client-side values.

Without a valid service account, the worker logs an error per queue entry and marks them as permanently failed (so they don't retry forever). Use test mode Firebase credentials for local development, or leave them unset to disable push entirely.