503 {"error":"push_not_configured"} and nothing is sent, so a deploy without the env is safe. Migration 039_push_subscriptions.sql is applied by hand to the production database.
Turn it on
Set these onallternit-cloud-api:
npx web-push generate-vapid-keys prints a pair in this format. Generate one pair for production and keep the private half in the secret store; changing it later drops every existing subscription (the browsers must subscribe again).
Routes
Subscription routes take a Clerk session or acompute-scoped API key.
GET /api/v1/push/vapid-key(public) returns{"publicKey": "B…"}. Clients pass it topushManager.subscribeasapplicationServerKey.POST /api/v1/push/subscriptions:
201 {"ok": true, "id": "ps_…"}. deviceId is a random id the app keeps in local storage; subscribing again from the same device replaces its row. The endpoint must be a public https host. A person may register 10 devices. If someone else signs in on the same browser, the endpoint moves to them. Errors: 400 bad_device_id | bad_endpoint | bad_keys, 429 too_many_devices.
DELETE /api/v1/push/subscriptionswith{"deviceId": "d_7f3a…"}returns{"ok": true, "removed": true}. Apps call it on sign-out and when the person turns notifications off.
404 or 410 means the browser dropped the subscription, and cloud-api deletes the row. Other failures are counted, and a subscription is removed after 8 in a row.
What gets pushed
The service worker receives JSON{type, title, body, url, tag, data}. url is a path on the app’s own origin; opening it lands on the call or thread.
channel_message only goes out for a runtime that doesn’t forward its events yet; a forwarding runtime’s messages come from the push sink with the sender and text instead, never both. Missed calls and in-app messages follow the person’s call.ended and message.received preferences; rings always go out.
Message-type pushes are also capped at 40 an hour per person. Calls are not (rings are already limited to 5 per person in 10 minutes). The tag is also sent as the push Topic, so a push service holds only the latest of each.
Where it lives
cmd/allternit-cloud-api/src/routes/web_push.rs. Senders: the push sink in notifications.rs for every backbone event (Notification preferences), spawn_notify for rings and spawn_notify_pref for missed calls and in-app messages (background, do nothing without the env), all through send_to_user. channel_inbound.rs calls notify_channel_message after a relayed message is delivered to the runtime; it pushes only when that runtime doesn’t forward its events. Tests use a fake transport and the RFC 8291 appendix A test vector.
Clients
The apps register throughallternit-ai/src/lib/gateway/web-push.ts and the worker public/allternit-push-sw.js. The worker has its own scope (/allternit-push/) because a service worker registration holds one push subscription, and the app and phone workers already hold one for approvals under a different VAPID key. A client that finds a subscription made under another key replaces it.
Clicking a notification opens the app on url, or posts {type: "ALLTERNIT_OPEN", url} to a running window. Allternit Desktop has no web push: it polls for rings while running and shows a system notification. Signing out calls DELETE /api/v1/push/subscriptions for the device.