Step-by-step guide to integrate push notifications into your app. Use the Docs tab for the full API reference.
Loading...
Add this file to your project's public/ folder. The pushsubscriptionchange handler is required — without it, subscriptions silently expire when Chrome rotates its push keys and users stop receiving notifications.
// public/sw.js
// Required for PWA installability (beforeinstallprompt won't fire without a fetch handler)
self.addEventListener("fetch", (event) => {
event.respondWith(fetch(event.request).catch(() => new Response("", { status: 503 })));
});
self.addEventListener("push", (event) => {
const data = event.data ? event.data.json() : {};
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: data.icon || "/favicon.png",
badge: data.badge || "/favicon.png",
image: data.image || undefined,
data: { url: data.url || "/", actions: data.actions || [] },
actions: (data.actions || []).map(a => ({ action: a.action, title: a.title })),
})
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const action = event.action;
const data = event.notification.data || {};
let url = data.url || "/";
if (action && data.actions) {
const found = (data.actions || []).find(a => a.action === action);
if (found && found.url) url = found.url;
}
event.waitUntil(
clients.matchAll({ type: "window", includeUncontrolled: true }).then(list => {
for (const c of list) { if (c.url === url && "focus" in c) return c.focus(); }
if (clients.openWindow) return clients.openWindow(url);
})
);
});
// IMPORTANT: Chrome periodically rotates push subscriptions.
// This handler catches the rotation and re-registers the new endpoint,
// preserving the userId link stored in the push service database.
// Without this, 410 errors will appear in your notification history.
self.addEventListener("pushsubscriptionchange", (event) => {
event.waitUntil(
(async () => {
const newSub = await self.registration.pushManager.subscribe(
event.oldSubscription.options
);
const subJson = newSub.toJSON();
// POST to your app's proxy — NOT directly to the push service
// (the push service needs the old endpoint to look up the userId)
await fetch("/api/push/resubscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
endpoint: newSub.endpoint,
keys: subJson.keys,
oldEndpoint: event.oldSubscription?.endpoint,
}),
});
})()
);
});
Drop this script tag before </body>. It handles SW registration, the push bell, and the install prompt automatically. The key is your project API key.
Loading...
Call window.scaffoldPush.identify(userId) after the user logs in — or in a useEffect that fires when user.id changes. This links the browser's existing push subscription to your user ID so you can send targeted notifications later. It is non-destructive: it never unsubscribes or re-subscribes.
Loading...
The SW's pushsubscriptionchange event fires from a background context where the user session cookie is not available, so it cannot call the push service directly. Add this unauthenticated proxy to your app server. The push service uses the oldEndpoint to look up the existing userId and transfers it to the new subscription automatically.
// Express — add alongside your other /api/push/* routes
// No requireAuth — called from the SW background context (no session available)
app.post("/api/push/resubscribe", async (req, res) => {
const { endpoint, keys, oldEndpoint } = req.body;
try {
await fetch(`${process.env.PUSH_SERVICE_URL}/resubscribe`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": process.env.PUSH_SERVICE_API_KEY,
},
body: JSON.stringify({ endpoint, keys, oldEndpoint }),
});
res.json({ ok: true });
} catch {
res.status(502).json({ error: "Push service unavailable" });
}
});
// Client-side — call on every page load when the user is logged in.
// If the browser dropped the subscription silently (SW update, idle eviction),
// this re-subscribes using the existing notification permission without prompting.
async function ensurePushSubscription(userId) {
if (!("serviceWorker" in navigator) || !("PushManager" in window)) return;
if (Notification.permission !== "granted") return;
const reg = await navigator.serviceWorker.getRegistration("/sw.js");
if (!reg) return;
const existing = await reg.pushManager.getSubscription();
if (existing) return; // still alive — nothing to do
// Silently re-subscribe and re-register with the push service
await subscribeToPush(userId); // your existing subscribe function
}
If you are not using widgets.js, handle the subscribe flow yourself. Pass userId in the body to link the subscription from the start.
Loading...
Broadcast to all subscribers or target a specific user with targetUserId.
Loading...
iOS Safari ignores manifest.json entirely for home-screen installation. Without these tags, "Add to Home Screen" always creates a plain bookmark — the app opens in Safari with a URL bar instead of running standalone. Add them to your app's index.html <head>.
<!-- iOS PWA — required for standalone installation -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Your App Name" />
<link rel="apple-touch-icon" href="https://YOUR_PUSH_SERVICE/pwa/icon/PROJECT_ID/192.png" />
<meta name="theme-color" content="#000000" />
<!-- Notes:
apple-mobile-web-app-capable → the key tag — enables standalone mode on iOS
apple-mobile-web-app-status-bar-style → hides Safari UI when launched from home screen
values: "default" (white bar), "black", "black-translucent" (overlaps content)
apple-touch-icon → home screen icon; iOS ignores manifest icons entirely
theme-color → status bar tint colour on iOS and Android
-->
Push notifications on iOS additionally require the app to be running in standalone mode (iOS 16.4+, Safari only). Chrome on iOS creates a bookmark, not a PWA — tell iOS Chrome users to open the install page in Safari instead.
Everything you need to wire up push notifications in a new app.
- Create a project in the Projects tab — copy your API key
- Set env vars on your app server:
PUSH_SERVICE_URL,PUSH_SERVICE_API_KEY - Add
public/sw.jsto your app — must include thepushsubscriptionchangehandler (Integration Guide step 2) - Add
widgets.jsscript tag to yourindex.html(Integration Guide step 3) - Call
window.scaffoldPush.identify(userId)on login (Integration Guide step 4) - Add the unauthenticated
POST /api/push/resubscribeproxy to your app server (Integration Guide step 5) - Call
ensurePushSubscription(userId)on each page load when the user is logged in (Integration Guide step 5) - Call
POST /notifywithtargetUserIdfrom your server when events occur - Add Apple PWA meta tags to your
index.htmlfor iOS standalone installation (Integration Guide step 8)
Loading...
Returns the VAPID public key needed by the browser to create a push subscription. No authentication required.
// Response
{ "publicKey": "BExampleVapidPublicKey..." }
Registers or updates a push subscription. Requires x-api-key header. If the endpoint already exists it is updated (upsert). Pass userId to link the subscription to a user in your system, and userName to show a human-readable name in the admin Users tab.
// Headers
x-api-key: YOUR_PROJECT_API_KEY
Content-Type: application/json
// Body
{
"endpoint": "https://fcm.googleapis.com/...",
"keys": {
"p256dh": "...",
"auth": "..."
},
"userId": "42", // optional — your internal user ID
"userName": "alice" // optional — display name shown in admin Users tab
}
// Response 201
{ "id": 7, "endpoint": "https://fcm...", "userId": "42", "userName": "alice" }
Sends a push notification. Requires x-api-key header. Omit targetUserId to broadcast to all subscribers.
// Headers
x-api-key: YOUR_PROJECT_API_KEY
Content-Type: application/json
// Body — broadcast to all subscribers
{
"title": "New post",
"body": "Check out what's new in the community.",
"url": "https://yourapp.com/posts/42",
"icon": "https://yourapp.com/icon-512.png" // optional
}
// Body — target one specific user
{
"title": "You got a mention",
"body": "@alice mentioned you in a post.",
"url": "https://yourapp.com/posts/42",
"targetUserId": "7" // your internal user ID passed to /subscribe
}
// Response
{ "sent": 1, "failed": 0, "expired": 0 }
Removes a push subscription by endpoint. Requires x-api-key header.
// Headers
x-api-key: YOUR_PROJECT_API_KEY
Content-Type: application/json
// Body
{ "endpoint": "https://fcm.googleapis.com/..." }
// Response
{ "ok": true }
Called by your app server's /api/push/resubscribe proxy when the browser's pushsubscriptionchange SW event fires. Swaps the old endpoint for the new one and preserves both the userId and userName links. Requires x-api-key header. Do not call this endpoint directly from the browser — proxy it through your app server.
// Headers
x-api-key: YOUR_PROJECT_API_KEY
Content-Type: application/json
// Body
{
"endpoint": "https://fcm.googleapis.com/...", // new endpoint from browser
"keys": {
"p256dh": "...",
"auth": "..."
},
"oldEndpoint": "https://fcm.googleapis.com/..." // previous endpoint (used to look up userId + userName)
}
// Response
{ "ok": true }
Available after widgets.js loads. Non-destructive — reads the current browser push subscription and posts it to /subscribe with your userId. Call this whenever the user logs in or on page load when the session is active.
// React / any framework — call after login or in useEffect on user change
window.scaffoldPush?.identify(user.id);
// Express server-side — inject userId + userName via session when browser calls /subscribe
// (alternative to client-side identify when you proxy the subscribe call)
app.post("/api/push/subscribe", requireAuth, async (req, res) => {
const userId = String(req.user.id);
const userName = req.user.username || req.user.email; // shown in admin Users tab
await fetch(`${PUSH_SERVICE_URL}/subscribe`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-api-key": PUSH_SERVICE_API_KEY },
body: JSON.stringify({ ...req.body, userId, userName }),
});
});
Loading...
The install page at /install/:slug automatically generates a full SEO and social sharing head based on your project settings. Configure in the Install Page tab.
// Meta tags auto-generated on every install page: <meta name="description" content="Your app description" /> <meta name="robots" content="index,follow" /> // or noindex,nofollow <link rel="canonical" href="https://yourapp.com/install" /> // Open Graph (Facebook, iMessage, Slack, WhatsApp…) <meta property="og:type" content="website" /> <meta property="og:title" content="Install Your App" /> <meta property="og:description" content="Your app description" /> <meta property="og:image" content="https://…/pwa/seo-image/:id" /> <meta property="og:image:width" content="1200" /> <meta property="og:image:height" content="630" /> <meta property="og:url" content="https://yourapp.com/install" /> // Twitter / X <meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:title" content="Install Your App" /> <meta name="twitter:description" content="Your app description" /> <meta name="twitter:image" content="https://…/pwa/seo-image/:id" /> // Social image is served at: GET /pwa/seo-image/:projectId // public — no auth required, cacheable 24h // Upload via admin (auto-resized to 1200×630 JPEG): POST /admin/projects/:id/seo-image // x-admin-key header, multipart/form-data field: image
Liveness check. No authentication required.
// Response
{ "ok": true }
en, pt)productivity, utilities)