Web Push Notifications
ChurchApps web apps deliver push notifications via the W3C Web Push API — the same mechanism used by Firebase Cloud Messaging on the server side, but delivered through the browser's native PushManager instead of FCM. A single VAPID key pair on the MessagingApi covers every consumer (B1Admin, B1App, future PWAs).
When push fires
The MessagingApi delivers a Web Push message in three situations, all routed through Api/src/modules/messaging/helpers/NotificationHelper.ts:
- Group / content notifications — someone replies to a thread the user follows or is mentioned in.
- Private messages —
POST /messaging/privatemessagestriggers a push to the recipient's enrolled devices. - Generic notifications — direct calls to
POST /messaging/notifications/createor/ping.
Push is the last-resort tier in NotificationHelper's escalation ladder. If a recipient has an active WebSocket connection in the relevant room (see Real-time Architecture), they receive the message in-app and push is suppressed for that delivery. Push only fires when the user is offline or hasn't been seen recently.
Server flow
NotificationHelper.checkShouldNotify(...)
│
├─ in-page socket delivery via DeliveryHelper ← preferred
│
└─ NotificationHelper.<sendXxx>(...)
└─ WebPushHelper.sendBulkTypedMessages(tokens, title, body, type, contentId)
└─ web-push library → VAPID-signed POST → browser push service
Required environment variables
VAPID keys are stored in Environment and must be present for push to be enabled:
| Variable | Description |
|---|---|
webPushPublicKey | VAPID public key (base64url). Returned to clients via GET /messaging/webpush/publicKey |
webPushPrivateKey | VAPID private key. Used to sign every outbound push |
webPushSubject | mailto: URI reported to push services. Defaults to mailto:support@churchapps.org |
WebPushHelper.isEnabled() returns false when either key is missing — the messaging module continues to operate, push deliveries simply no-op.
Generating a VAPID key pair
npx web-push generate-vapid-keys
Add the output to your .env (local) or AWS SSM Parameter Store (deployed). Rotating keys invalidates every existing subscription — clients must re-enroll on next page load.
Storage model
Web Push subscriptions are stored in the existing devices table alongside FCM device records. They're distinguished by a webpush: prefix on the fcmToken column:
fcmToken = "webpush:" + JSON.stringify({ endpoint, keys: { p256dh, auth } })
This lets a single loadByPersonId call return every device a user has enrolled, regardless of platform. WebPushHelper.isWebPushToken(token) and decodeSubscription(token) handle the prefix logic.
Endpoints
Base path: /messaging/webpush
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /publicKey | Public | Returns { publicKey, enabled }. Clients pass publicKey to pushManager.subscribe({ applicationServerKey }) |
| POST | /subscribe | JWT | Registers (or upserts) a subscription for the authenticated user. Body: { subscription: { endpoint, keys: { p256dh, auth } }, appName?, deviceInfo?, label? } |
| POST | /unsubscribe | Public | Deletes any device row whose fcmToken contains the given endpoint. Body: { endpoint } |
| DELETE | /subscription/:id | JWT | Deletes a specific device row by its server-side id |
Client primitive: WebPushHelper
@churchapps/apphelper's WebPushHelper is the single client-side entry point. Hosts configure it once at boot and call subscribe() after login.
import { WebPushHelper } from "@churchapps/apphelper";
// In your app's bootstrap (e.g., _app.tsx, layout.tsx)
WebPushHelper.configure({
scope: "/", // service worker scope; matches sw.js registration
appName: "B1AppPwa" // stored on the device row, useful for filtering by surface
});
// After login (and after every userChurch change)
await WebPushHelper.subscribe();
Behaviors that consumers get for free:
- Capability check —
isSupported()returnsfalseon browsers withoutserviceWorker/PushManager/Notification. - Cooldown —
canPromptNow()enforces a 7-day cooldown between prompts vialocalStorageso users who dismiss the OS prompt aren't asked again on every session. - Opt-out —
setOptedOut(true)andunsubscribe()block re-prompting and remove the server-side device row. - Standalone-PWA detection —
isStandalone()lets hosts gate iOS push prompts behind "user has installed the PWA to their home screen" (iOS only allows push from installed PWAs). - Re-enroll on church switch —
refreshEnrollment()reposts the existing browser subscription against the newuserChurchwithout re-prompting the user. Call it from theuserChurchchange handler.
Service worker requirement
The browser's PushManager only resolves a subscription when a service worker is registered at the configured scope. ChurchApps PWAs use Serwist (Next.js apps) or workbox for service worker generation. The service worker must include a push event handler that calls self.registration.showNotification(title, options) to render the OS-level notification when the push arrives:
// public/sw.js (or whatever Serwist/workbox emits)
self.addEventListener("push", (event) => {
const data = event.data?.json() ?? {};
const title = data.title || "ChurchApps";
event.waitUntil(self.registration.showNotification(title, {
body: data.body,
data: { type: data.type, contentId: data.contentId },
icon: "/icons/icon-192.png"
}));
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const { type, contentId } = event.notification.data || {};
event.waitUntil(self.clients.openWindow(deepLinkFor(type, contentId)));
});
deepLinkFor is consumer-specific — B1App routes privateMessage to /mobile/messages/:id, B1Admin routes notification to its alerts panel, etc.
Operational notes
gone: trueresults —WebPushHelper.sendBulkreturns{ token, success, gone, errorMessage }per recipient. Agone: trueresult (push service responded404or410) means the subscription is permanently invalid; downstream code inNotificationHelperdeletes those device rows so they aren't tried again.- TTL — push messages are sent with
TTL: 86400(24 hours). If the user's browser doesn't connect to the push service within 24 hours, the push is dropped. - No retries — a transient failure (timeout, 5xx) is logged and not retried. Push is best-effort; the in-page socket and the email notification tier handle the durability story.
- Disabled environments — staging and dev environments can leave the VAPID keys empty;
WebPushHelper.isEnabled()will returnfalseand pushes will short-circuit. This is the intended behavior for environments without their own VAPID identity.
Related Pages
- Real-time Architecture -- WebSocket delivery; push is the offline-fallback for the same notifications
- Messaging Endpoints -- Notifications, devices, and the rest of the messaging surface
- AppHelper -- The npm package that ships
WebPushHelper