Push Notifications
Send real Web Push notifications to your users’ devices — the kind that appear even when the tab is closed. Send them immediately, or schedule one for later (an hour before an event, every morning at 9am, every Monday). VAPID keys, service worker, encryption, subscription storage, and scheduling are all handled for you. You call platform.push.send(...) or platform.push.schedule(...) and your app gets picked up.
// Browser: user clicks a "Notify me" button in your app.
import { registerPush } from '@maravilla-labs/platform/push';
await registerPush({ topics: ['waitlist'] });
// Server: later, when something interesting happens.
await platform.push.send(
{ topic: 'waitlist' },
{ title: 'Doors open!', body: 'Your spot is ready.', url: '/event/123' }
);
No VAPID keys to generate. No service worker to write. No pushManager.subscribe dance to wire up.
How It Works
- Your project gets its own VAPID keypair the first time you enable Push. Keys are stored encrypted per-project — we never share a keypair between tenants.
- Your app imports
registerPushfrom@maravilla-labs/platform/push. It registers our service worker, grabs your VAPID public key, asks for permission, and sends the subscription to the platform. - From server-side code you call
platform.push.send(target, notification). We look up every matching subscription, sign a VAPID JWT, encrypt the payload (aes128gcm), and fire it off to the browser’s push service (Mozilla, Google FCM, Apple, Microsoft). - The browser wakes the service worker, which calls
showNotification()with your payload.
Dead subscriptions (410 Gone) are detected on send and marked inactive automatically. You don’t need a cleanup job.
Quick Start
1. Enable Push in project settings
Open your project on Maravilla Cloud → Settings → Modules → Push Notifications and toggle it on. The first VAPID keypair is generated the moment the first visitor fetches the public key — there’s nothing to configure.
2. Register a subscription from the browser
// SvelteKit, React, anything — runs on the client.
import { registerPush } from '@maravilla-labs/platform/push';
async function enableNotifications() {
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;
const { subscriptionId } = await registerPush({
topics: ['new-posts', 'weekly-digest'],
});
console.log('subscribed:', subscriptionId);
}
That’s the entire client. Always gate the call behind a user-initiated click — browsers throttle permission prompts that fire on page load.
3. Send from your server-side code
// SvelteKit server action, Nitro route, or anywhere `platform` is available.
import { getPlatform } from '@maravilla-labs/platform';
const platform = getPlatform();
await platform.push.send(
{ topic: 'new-posts' },
{
title: 'New post: Release 0.2',
body: 'Check out what shipped this week',
icon: '/icons/bell.png',
url: '/posts/release-0-2',
}
);
Anonymous vs. authenticated users
Push works for both out of the box. You choose what identifies a subscription:
// Logged-in user: subscription is bound to their account.
await registerPush({
userId: auth.user.id,
topics: ['account-alerts'],
});
// Anonymous visitor: subscription is bound to a visitor id.
// If you omit visitorId, the SDK generates one and stores it in localStorage.
await registerPush({
visitorId: 'guest-' + crypto.randomUUID(),
topics: ['waitlist', 'launch-day'],
});
On the send side you target however you want:
// One specific authenticated user, every device they've registered on.
await platform.push.send({ userId: 'u_42' }, notification);
// Everyone subscribed to a topic (mixed authenticated + anonymous).
await platform.push.send({ topic: 'waitlist' }, notification);
// An individual anonymous device.
await platform.push.send({ visitorId: 'guest-abc' }, notification);
// Combine filters — "the owner, for this specific invite".
await platform.push.send(
{ userId: 'u_42', topic: 'invite:abc:rsvp' },
notification
);
Topics are free-form strings. Use them however you like: invite:{id}, room:{id}:messages, order:{id}:shipped, waitlist. You don’t create topics in advance; they exist the moment a subscription references one.
Server-Side API
Access through platform.push in your server-side code.
platform.push.send(target, notification)
Fan out a notification to every active subscription matching target.
Parameters:
target— one of:{ userId }— all devices for this user{ visitorId }— a specific anonymous device{ topic }— every subscription tagged with this topic{}— all active subscriptions in the project- Fields can be combined to narrow further (all conditions must match).
notification:title(string, required)body(string, optional)icon,badge,image(string, optional) — URLstag(string, optional) — browsers dedupe notifications sharing a tagurl(string, optional) — where to go when the notification is clickeddata(object, optional) — arbitrary JSON passed to the service workerttl(number, optional) — seconds the push server holds the message if the device is offlineurgency('very-low' | 'low' | 'normal' | 'high', optional)
Returns:
{
attempted: number, // subscriptions matched
succeeded: number,
gone: number, // 404/410 — auto-marked inactive
failed: number,
errors: Array<{ subscriptionId: string, message: string }>
}
platform.push.list(filter?)
List subscriptions for the current project. Useful for admin UIs and debugging.
const subs = await platform.push.list({ topic: 'waitlist', onlyActive: true });
platform.push.unsubscribe(subscriptionId) / unsubscribeByEndpoint(endpoint)
Remove a subscription. Either the SDK’s unregisterPush or a server-side call works.
platform.push.counts()
Aggregate counts by topic and provider — wire this up to a dashboard in one line.
const { total, byTopic, byProvider } = await platform.push.counts();
platform.push.getVapidConfig() / rotateVapidKeys()
Fetch your project’s current VAPID public key, or rotate the keypair. Rotating invalidates every existing subscription, so confirm with your users first.
Scheduled notifications
Send a notification at a future time — a reminder an hour before an event, a morning digest at 9am, a weekly update every Monday. The same target + notification shape as send, with an at timestamp and an idempotency key so reschedules replace cleanly.
// Remind every RSVP'd guest one hour before the event.
await platform.push.schedule(
{ topic: `invite:${invite.id}` },
{ title: invite.title, body: 'Your event is in one hour', url: `/i/${invite.nanoid}` },
{
at: new Date(invite.event_date.getTime() - 60 * 60 * 1000),
key: `invite:${invite.id}:reminder-1h`,
}
);
If the event date changes, re-call schedule with the same key. The pending reminder is replaced atomically — no double-fire, no leftover timer. If the event is canceled, call cancelScheduled('invite:…:reminder-1h').
platform.push.schedule(target, notification, opts)
Options:
at(Date | string, required) — when to send. ISO-8601 orDate; always UTC on the server.key(string, required) — idempotency key scoped to your project. Re-callingschedulewith the same key replaces the prior pending job.maxAttempts(number, optional) — how many delivery attempts before the job is marked failed. Defaults to 3.everySeconds(number, optional) — if set, the job re-queues after every successful send and fires again that many seconds later. Use this for daily digests (86400), weekly updates (604800), or any fixed-interval loop.cancelScheduled(key)stops it.
Returns { jobId }.
platform.push.cancelScheduled(key) / listScheduled(filter?) / getScheduled(key)
Cancel a pending job, enumerate everything you’ve scheduled, or look up a single job. listScheduled({ status: 'pending' | 'running' | 'succeeded' | 'failed' }) filters by state.
A daily 9am digest
// First time the user opts in — schedule their daily digest.
const next9am = new Date();
next9am.setUTCHours(9, 0, 0, 0);
if (next9am < new Date()) next9am.setUTCDate(next9am.getUTCDate() + 1);
await platform.push.schedule(
{ userId: user.id, topic: 'daily-digest' },
{ title: 'Your morning briefing', url: '/digest' },
{
at: next9am,
key: `digest:${user.id}`,
everySeconds: 86400,
}
);
One call, and the digest keeps firing every 24 hours until the user unsubscribes. To turn it off, cancelScheduled(\digest:${user.id}`)`.
Reliability
Scheduled sends survive deployments and restarts. If a delivery attempt hits a transient error (network blip, provider timeout), the job retries with backoff up to maxAttempts. Succeeded, failed, and canceled jobs stay in the history for inspection — they don’t silently disappear.
Client SDK
@maravilla-labs/platform/push ships two functions. That’s it.
registerPush(options?)
Registers the service worker (/_rt/push/sw.js, same-origin), asks the browser for a subscription, and sends it to the platform.
Options:
topics(string[]) — tag this subscription with topics you’ll target on send.userId(string) — bind to an authenticated user. Takes precedence overvisitorId.visitorId(string) — bind to an anonymous device. If both are omitted, a stable UUID is generated and persisted tolocalStorageundermaravilla.push.visitorId.swPath,basePath— overrides for self-hosted setups.
Returns: { subscription, subscriptionId } — the browser’s PushSubscription plus the server-issued id (keep it around if you want to unsubscribe later).
const { subscriptionId } = await registerPush({
userId: 'u_42',
topics: ['billing'],
});
localStorage.setItem('my-app:push-id', subscriptionId);
unregisterPush(subscriptionId)
Removes the subscription server-side. Idempotent; treats 404 as success so you can call it defensively.
const id = localStorage.getItem('my-app:push-id');
if (id) await unregisterPush(id);
Example: per-event push in 20 lines
A birthday-invite app that pushes the host when any guest RSVPs, and pushes anonymous guests if the host changes the venue:
// +page.server.ts — guest submits RSVP on /i/[nanoid]
import { getPlatform } from '@maravilla-labs/platform';
export const actions = {
submitRsvp: async ({ params, request }) => {
const form = await request.formData();
const platform = getPlatform();
// ... persist the RSVP to KV ...
// Ping the host's device. Topic + userId means "their subscription for this invite".
await platform.push.send(
{ userId: invite.owner, topic: `invite:${invite.id}:rsvp` },
{ title: `${guest.name} said ${form.get('status')}`, url: `/invites/${invite.id}` }
);
},
updateVenue: async ({ params, request }) => {
// ... persist the new venue ...
await platform.push.send(
{ topic: `invite:${invite.id}` }, // everyone who opted in
{ title: 'Venue change', body: `Now at ${newVenue}`, url: `/i/${nanoid}` }
);
},
};
On the guest’s side:
// After the guest submits their RSVP, show an opt-in button.
import { registerPush } from '@maravilla-labs/platform/push';
<button onclick={async () => {
if ((await Notification.requestPermission()) !== 'granted') return;
await registerPush({ visitorId: invitee.nanoid, topics: [`invite:${invite.id}`] });
}}>
Notify me if details change
</button>
Five lines of server code. Four lines of client code. A full multi-party notification flow.
Admin UI
Every project gets a Push Notifications page under Settings:
- View the VAPID public key (with a copy button)
- Rotate the VAPID keypair (with a confirm dialog — rotation invalidates all existing subscriptions)
- Change the contact email embedded in the VAPID JWT
- Browse subscribers with filters for topic, user, and activity
- Delete individual subscriptions
- See counts grouped by topic and provider
- Watch the Scheduled queue update live — every job you’ve scheduled, its next fire time, and its status (pending, running, succeeded, failed). Cancel a job with one click if plans change.
Isolation
Each project gets its own VAPID keypair, generated on first use and encrypted at rest. Nothing is shared between projects — rotating or revoking your keys has no effect on any other tenant.
Browser support
Web Push works in every modern browser:
| Browser | Support |
|---|---|
| Chrome, Edge, Opera (desktop + Android) | ✅ |
| Firefox (desktop + Android) | ✅ |
| Safari 16.4+ (macOS, iOS, iPadOS) | ✅ — requires the site to be installed to home screen on iOS |
APNs and FCM transport will slot in behind the same registerPush / platform.push.send API when we ship native-mobile support — you won’t need to rewrite anything.
Next Steps
- Real-Time Events (REN) — trigger pushes from KV, database, or storage mutations without a separate server call
- Authentication — bind subscriptions to authenticated
userIds for targeted pushes - Platform Overview — how Push fits with KV, Database, Storage, and Realtime