Push Service

Sign in to your account

Overview

YOUR PWAS
Projects
Loading…
Projects
Total Subscribers
Notifications Sent
Loading…
Send Push Notification
Project
Title0/50
Body0/100
Image URL — large image in notification body (optional)
URL to open (optional)
Action Buttons (optional, max 2)
Title Body Sent Failed Date
Select a project
Select a project

Step-by-step guide to integrate push notifications into your app. Use the Docs tab for the full API reference.

1. Get your VAPID public key
Loading...
2. Register service worker

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,
        }),
      });
    })()
  );
});
3. Add widgets.js (recommended)

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...
4. Identify logged-in users

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...
5. Add the resubscribe proxy to your app server

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
}
6. Subscribe users manually (alternative to widgets.js)

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...
7. Send notifications from your server

Broadcast to all subscribers or target a specific user with targetUserId.

Loading...
8. iOS: add Apple meta tags to your HTML

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.

New Project Checklist

Everything you need to wire up push notifications in a new app.

  1. Create a project in the Projects tab — copy your API key
  2. Set env vars on your app server: PUSH_SERVICE_URL, PUSH_SERVICE_API_KEY
  3. Add public/sw.js to your app — must include the pushsubscriptionchange handler (Integration Guide step 2)
  4. Add widgets.js script tag to your index.html (Integration Guide step 3)
  5. Call window.scaffoldPush.identify(userId) on login (Integration Guide step 4)
  6. Add the unauthenticated POST /api/push/resubscribe proxy to your app server (Integration Guide step 5)
  7. Call ensurePushSubscription(userId) on each page load when the user is logged in (Integration Guide step 5)
  8. Call POST /notify with targetUserId from your server when events occur
  9. Add Apple PWA meta tags to your index.html for iOS standalone installation (Integration Guide step 8)
Base URL
Loading...
GET /vapid-public-key

Returns the VAPID public key needed by the browser to create a push subscription. No authentication required.

// Response
{ "publicKey": "BExampleVapidPublicKey..." }
POST /subscribe

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" }
POST /notify

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 }
POST /unsubscribe

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 }
POST /resubscribe

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 }
window.scaffoldPush.identify(userId)

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 }),
  });
});
Environment Variables (app server)
Loading...
Install Page — SEO & Social Sharing

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
GET /health

Liveness check. No authentication required.

// Response
{ "ok": true }
Connected Project
All widgets are pre-configured with this project's API key
What makes a PWA?
HTTPS — Railway and Vercel provide this automatically.
Service Worker — copy sw.js from UI Components → Service Worker.
Web App Manifest — configure below. The push service hosts it for you — no file to manage.
App Icon — upload your logo in Projects. It becomes the PWA icon automatically.
Project
Manifest Settings
App URL (production origin — fixes start_url)
App Name (full name)
Short Name (home screen, ≤12 chars)
Theme Color (browser toolbar)
Background Color (splash screen)
Display Mode
Language (IANA tag, e.g. en, pt)
Description (shown on install page + manifest)
Categories (comma-separated, e.g. productivity, utilities)
Used by app stores for classification. Valid categories list
Project
VAPID Public Key
Share this with your client apps
Loading...
Service URL
Session