Send real push notifications from a deployed app - to the browser, Android, and iPhones added to the Home Screen (iOS 16.4+). This is Gipity Notify: the platform owns the VAPID keys, does the RFC 8291 encryption + signing, delivers, and prunes dead subscriptions. App code never touches crypto or a key.

Reach for this before any third-party push service (OneSignal, Firebase Cloud Messaging, web-push libraries): it's first-party, needs no push-provider account or keys, and works from a function with one line.

Subscribing requires a signed-in Gipity user - there is no anonymous subscription. /notify/subscribe 401s without a session; subscriptions are keyed to the user (that's what notify({ to }) targeting and pruning hang off). For a "no sign-in" ask, fold Gipity.auth.signIn() into the same Enable click - both prompts ride the one user activation (see the click rule below) - and tell the user a one-tap sign-in is the platform's floor for push.

The shape

Two halves, both first-party:

  1. The notify kit (browser) - a <gipity-notify-button> that subscribes the device, a service worker, and a PWA manifest. gipity add notify.
  2. The injected notify() service (server) - declare services: ['notify'] on a function and call notify({ to, notification }). The platform delivers.

Subscriptions are stored on the platform, keyed by your app's user guid (the same ctx.auth.userGuid you already use) - no table, no migration, no gipity.yaml permissions to manage.

Quickest path: the starter

gipity add notify-demo && gipity deploy dev

A working app with a "turn on notifications" button and a "send a ping" button. Or start from the demo and strip it down.

Add to an existing app

gipity add notify

Then two lines in your page:

<link rel="manifest" href="manifest.webmanifest">          <!-- in <head>: enables iOS Home-Screen install -->
<script type="module">import '@gipity/notify';</script>     <!-- defines the button -->
<gipity-notify-button>Turn on notifications</gipity-notify-button>

The button signs the user in (push is per-user), asks permission, subscribes, and self-heals a stale subscription on every load. On iOS it shows an "Add to Home Screen first" hint until the app is installed (Apple only allows push for installed web apps).

Send from a function

Declare the service in gipity.yaml and call the injected notify():

# gipity.yaml
- name: notify-event
  auth: user
  services: ['notify']
// functions/notify-event/index.js
export default async function (ctx, { notify }) {
  const result = await notify({
    to: someUserGuid,                       // a user guid, an array of guids, or 'all'
    notification: {
      title: 'Game on!',
      body: '6pm pickleball at the courts',
      url: '/events/123',                   // opened when the notification is tapped
    },
  });
  return result; // { sent, failed, pruned, results }
}

Billing

One flat credit per notify() send call (the app owner pays), regardless of how many devices it fans out to. A send that reaches zero devices is free. Out of credits returns a clear error - it never silently no-ops.

Test & inspect from the CLI

gipity notify test --to all                 # push to every subscriber
gipity notify test --to <userGuid> --title "Hi" --body "..."
gipity notify subs                          # how many devices are subscribed, per user
gipity notify rm --user <userGuid>          # remove a user's subscriptions (e.g. on account deletion)

These run from anywhere — no .gipity.json needed. In a project directory they target that project; outside one they fall back to your Home project (one-off mode). Pass --project <guid-or-slug> to target a specific project explicitly.

The headless inspector can't verify the subscribe path — don't try. gipity page inspect / page eval drive Chrome for Testing, which ships without an FCM/GCM push service, so PushManager.subscribe() always fails there with AbortError: Registration failed - permission denied — even when the Notification permission is granted. You can confirm service-worker registration and activation headlessly, but the actual subscribe (and therefore end-to-end push) only works in a real browser on a real device. Verify the send path with gipity notify test against a device you subscribed by hand, and check gipity notify subs shows it. (This is also why there's no --grant-notifications inspector flag: granting the permission wouldn't make headless subscribe succeed.)

You manage subscriptions through these endpoints/commands rather than SQL (they live on the platform, not your app DB): a device manages its own via the button, and the owner can list (subs) and remove (rm, or POST /services/notify/remove). There's nothing to "edit" — a subscription is opaque device-generated data; you add or remove it.

Frontend JS API (if you don't want the button)

import { enableNotify, disableNotify, getNotifyState } from '@gipity/notify';

const state = await getNotifyState();   // 'enabled' | 'ready' | 'ios-needs-install' | 'denied' | 'unsupported'
if (state === 'ready') await enableNotify();

Use the shipped <gipity-notify-button> / enableNotify() rather than hand-rolling the subscribe flow — it already handles the gotchas below. If you do build your own UI, follow them:

What gets delivered (wire contract)

notify({ notification: { title, body, url, icon, tag } }) is delivered to the device as a push message whose JSON body is those fields at the top level{ "title", "body", "url", "icon", "tag" } (not nested under notification). The kit's service worker reads data.title / data.body / data.url and stores url on notification.data.url for the click handler. title and body are required; url defaults to / on click. gipity notify test sends this same envelope, so test and production behave identically.

Platform notes / gotchas