diff --git a/src/docs/src/Notifications.md b/src/docs/src/Notifications.md
index ae7032e9a..631362a8a 100644
--- a/src/docs/src/Notifications.md
+++ b/src/docs/src/Notifications.md
@@ -21,7 +21,7 @@ A notification always shows in the user's Puter notifications panel. Puter then
- **Live**, while your app is open and connected: it reaches the app through [`onNotification()`](/Notifications/onNotification/) and [`onPush()`](/Notifications/onPush/).
- **As a push**, while it isn't: to the browsers where the user turned pushes on for your app.
-For a push, the user has to have turned them on. On a website, call [`enable()`](/Notifications/enable/) from a click and handle pushes with [`onPush()`](/Notifications/onPush/); they arrive on your own origin through a small service worker. Inside the Puter desktop, pushes go through Puter: ask with [`requestPermission()`](/Notifications/requestPermission/), and [`enablePush()`](/Notifications/enablePush/) offers to turn them on in the browser.
+For a push, the user has to have turned them on. On a website, call [`enable()`](/Notifications/enable/) from a click and handle pushes with [`onPush()`](/Notifications/onPush/); they arrive on your own origin through a small service worker, and [Push notifications for your app](/Notifications/push/) has the setup step by step. Inside the Puter desktop, pushes go through Puter: ask with [`requestPermission()`](/Notifications/requestPermission/), and [`enablePush()`](/Notifications/enablePush/) offers to turn them on in the browser.
A user gets at most one push a minute from each app. A notification over that still shows in the panel; it's just not pushed.
diff --git a/src/docs/src/Notifications/push.md b/src/docs/src/Notifications/push.md
new file mode 100644
index 000000000..2b8abf16f
--- /dev/null
+++ b/src/docs/src/Notifications/push.md
@@ -0,0 +1,148 @@
+---
+title: Push notifications for your app
+description: Reach the user when your app isn't open, from a website on its own domain or from an app inside the Puter desktop.
+platforms: [websites, apps]
+---
+
+A push notification reaches the user when they have neither Puter nor your app open. It shows on their phone or desktop, and a tap opens your app. Your app sends it with [`notify()`](/Notifications/notify/), the same call that puts a notification in the user's Puter notifications panel. Puter decides the route. While your app is open and connected, the notification reaches the page live. While it isn't, it goes out as a push.
+
+This page is the setup. A website on its own domain needs three steps, and an app inside the Puter desktop needs one.
+
+## A website on its own domain
+
+On a website, pushes arrive on your own domain through a small service worker, and the browser asks the user for permission on your domain.
+
+### 1. Serve a service worker
+
+Put this file at the root of your site, as `/puter-sw.js`:
+
+```js
+importScripts('https://js.puter.com/v2/push-sw.js');
+```
+
+If your site already has a service worker, add that line to it and pass its path as `serviceWorker` to [`onPush()`](/Notifications/onPush/) and [`enable()`](/Notifications/enable/).
+
+### 2. Say what a push shows
+
+On every page load, register a handler with [`onPush()`](/Notifications/onPush/) for each kind of notification you send. The key is the `channel` you pass to [`notify()`](/Notifications/notify/).
+
+```js
+puter.notifications.onPush('chat', (n) => ({
+ title: `${n.app?.name ?? 'Chat'}: ${n.title}`,
+ url: n.target ?? '/',
+}));
+```
+
+While your app is open, the handler runs in the page as each notification arrives. While it isn't, the same handler runs in your service worker to decide what the push shows. The handler is saved as source and run again in the worker, so it has these limits:
+
+- It can't use variables, functions or imports from around it. Everything it needs comes from its argument or is written inside it.
+- It has no `window`, `document` or `localStorage`. `fetch`, `indexedDB` and `caches` are available.
+- It has 5 seconds. After that, or if it throws, the push shows the notification's own title and text.
+- It can change what the push shows but can't hide it. Browsers show a notification for every push.
+
+Returning nothing shows the notification's own `title` and `text`. The fields a handler can return are listed on [`onPush()`](/Notifications/onPush/).
+
+### 3. Let the user turn pushes on
+
+Call [`enable()`](/Notifications/enable/) from a click. Browsers only show their permission prompt from a user gesture.
+
+```js
+document.getElementById('enable').addEventListener('click', async () => {
+ const on = await puter.notifications.enable();
+ puter.print(on ? 'Notifications are on ' : 'Notifications are off ');
+});
+```
+
+The browser asks for permission on your domain. Puter then sends a confirmation push to that browser, and the browser is registered once your worker hands the confirmation back. Allowing it also lets your app's notifications go out as pushes. On later visits, [`onPush()`](/Notifications/onPush/) keeps the registration current without asking again.
+
+[`enable()`](/Notifications/enable/) resolves `true` when pushes are on in this browser, and `false` when the user didn't allow notifications or the browser can't receive pushes. It rejects with `push_revoked` when the user turned your app off in Puter's settings. That choice stands until the user allows your app again, so call [`requestPermission()`](/Notifications/requestPermission/) from a click, which opens Puter's permission prompt, and then call [`enable()`](/Notifications/enable/) again:
+
+```js
+document.getElementById('enable').addEventListener('click', async () => {
+ try {
+ await puter.notifications.enable();
+ } catch (error) {
+ if (error.code !== 'push_revoked') throw error;
+ if (await puter.notifications.requestPermission()) {
+ await puter.notifications.enable();
+ }
+ }
+});
+```
+
+To turn pushes off in this browser again, call [`disable()`](/Notifications/disable/). Notifications still reach your app live while it's open.
+
+## Sending a push
+
+Send the notification with [`notify()`](/Notifications/notify/), with the handler's key as `channel`:
+
+```js
+await puter.notifications.notify({
+ title: 'New message from Ana',
+ text: 'See you at 6?',
+ target: '/chats/42',
+ data: { chatId: 42 },
+ channel: 'chat',
+});
+```
+
+It works from the page and from a worker. The notification always shows in the user's Puter notifications panel. It goes out as a push only while the user has neither your app nor Puter open, and a user gets at most one push a minute from each app. The other limits are on [Rate Limits and Quotas](/rate-limits-and-quotas/#notifications).
+
+When the user taps the push, the browser opens the handler's `url` on your domain. When the user clicks the notification in the Puter panel instead, Puter opens your app and hands the `target` and `data` back through [`on('click')`](/Notifications/on/).
+
+## A minimal page
+
+Serve the page below from a site that has `/puter-sw.js` to try it. It registers the handler, turns pushes on from a click, and sends a notification. The notification arrives live while the page is open, and as a push while it isn't.
+
+```html;notifications-push
+
+
+
+
+
+
+
+
+```
+
+## An app inside the Puter desktop
+
+An app inside the Puter desktop needs no worker and no [`enable()`](/Notifications/enable/). Pushes go through Puter itself. Ask once, from a click:
+
+```js
+const allowed = await puter.notifications.requestPermission();
+```
+
+Puter's permission dialog opens. When the user allows it, Puter also turns pushes on in the browser they answered in, and your notifications go out as pushes from then on. For a user who already allowed your app, in a browser where pushes aren't on yet, [`enablePush()`](/Notifications/enablePush/) shows a one-button dialog that turns them on. [`hasPermission()`](/Notifications/hasPermission/) says whether the user allowed your app, without asking, so you can decide whether to show a button at all.
+
+While the app is open, [`onPush()`](/Notifications/onPush/) and [`onNotification()`](/Notifications/onNotification/) receive its notifications live, the same as on a website.
+
+## Good to know
+
+- Say who did what. "Ana shared Q3-report.pdf" reads well as a push. A push that only says there's something new can be hidden as spam on Android.
+- The `data` field is handed back on click, and push services can read it, so put IDs in it and fetch the rest. Set `sensitive: true` when the title and text shouldn't leave Puter. The push then shows a generic line, and the panel still shows the notification.
+- A push carries about 4 KB, with `data` at most 1 KB.
+- A tap opens your domain. The `url` a handler returns has to be on your origin, and other origins are replaced with your site's root.
+- Pushes from your app count toward the user's usage. See [What pushes cost](/rate-limits-and-quotas/#what-pushes-cost). Puter's own notifications are free.
diff --git a/src/docs/src/examples.js b/src/docs/src/examples.js
index 0aa64d529..bcc7110fd 100644
--- a/src/docs/src/examples.js
+++ b/src/docs/src/examples.js
@@ -717,6 +717,12 @@ const examples = [
slug: 'notifications-notify',
source: '/playground/examples/notifications-notify.html',
},
+ {
+ title: 'Push Notifications',
+ description: 'Turn on push notifications for a website on its own domain and send one with the Puter.js Notifications API. Run it in the playground.',
+ slug: 'notifications-push',
+ source: '/playground/examples/notifications-push.html',
+ },
],
},
{
diff --git a/src/docs/src/playground/examples/notifications-push.html b/src/docs/src/playground/examples/notifications-push.html
new file mode 100644
index 000000000..0f253f886
--- /dev/null
+++ b/src/docs/src/playground/examples/notifications-push.html
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
diff --git a/src/docs/src/rate-limits-and-quotas.md b/src/docs/src/rate-limits-and-quotas.md
index c2bfcbb93..d1e62f88a 100644
--- a/src/docs/src/rate-limits-and-quotas.md
+++ b/src/docs/src/rate-limits-and-quotas.md
@@ -403,6 +403,8 @@ An app, its workers and the tokens it issues can send the user they act for an `
| Limit | Value | Scope |
| -------------------------------- | --------------------------------- | ----------------- |
| `app.message` notifications | 30/min, 300/day | Per user, per app |
+| Pushes | 1/min, 100/day | Per user, per app |
+| Pushes while an app is new | 20/day | Per user, per app |
| `title` | 120 characters | |
| `text` | 240 characters | |
| `icon` (an `https` URL) | 2,048 characters | |
@@ -416,6 +418,8 @@ An app, its workers and the tokens it issues can send the user they act for an `
Over the rate limit the call fails with `429 too_many_requests`, and a refused call counts against neither window. A value over any size limit, or with a field the format doesn't have, fails with `400 bad_request`. A deployment can change the rate limits with `notificationAppMessageLimits: { perMinute, perDay }`.
+A notification goes out as a push only while the user has neither Puter nor the app open, and the push limits count those. An app is new for its first 30 days, or until it's approved for listing, whichever is later. Over a push limit the call still succeeds and the notification still shows in the user's notifications panel. It just isn't pushed. Pushes are billed to the user who receives them. See [What pushes cost](#what-pushes-cost).
+
### Teams
Available only where the deployment has teams turned on; elsewhere `puter.teams` rejects with `not_found`.
@@ -586,6 +590,16 @@ Free: idle subscriptions, events a filter excluded, writes merged by coalescing,
When the holder's balance runs out, deliveries stop and persistent subscriptions are suspended with `no_credit`, and the holder is notified. The backlog is kept for 1 hour. Topping up resumes them within a few minutes.
+#### What pushes cost
+
+A push an app sends with [`puter.notifications.notify()`](/Notifications/notify/) is billed to the user who receives it:
+
+| What | Rate | Counted per |
+| --------------------------- | -------------------------- | ----------------------------- |
+| A push from an app | 200 ยตยข ($2 per million) | Device that received the push |
+
+A push to three of the user's devices counts three times. A push that didn't reach a device isn't charged. Puter's own notifications, such as a share or a finished deploy, are free.
+
### Networking
[`puter.net`](/Networking/) sockets connect through a relay that takes a token. Each token is valid for 24 hours, or until the session that created it ends. A page fetches one token when it opens its first socket and reuses the connection for later sockets; `puter.net.generateWispV1URL()` fetches a new one on every call.
diff --git a/src/docs/src/recipe-tags.js b/src/docs/src/recipe-tags.js
index 8efa21ea0..adb1b6bb5 100644
--- a/src/docs/src/recipe-tags.js
+++ b/src/docs/src/recipe-tags.js
@@ -11,6 +11,7 @@ const recipeTags = {
fs: 'File System',
kv: 'Key-Value',
events: 'Events',
+ notifications: 'Notifications',
hosting: 'Hosting',
workers: 'Workers',
email: 'Email',
diff --git a/src/docs/src/recipes/push-when-closed.md b/src/docs/src/recipes/push-when-closed.md
new file mode 100644
index 000000000..426d0e4c4
--- /dev/null
+++ b/src/docs/src/recipes/push-when-closed.md
@@ -0,0 +1,118 @@
+---
+title: Reach a User When Your App Is Closed
+description: "Learn how to send push notifications from your app with Puter.js, so users hear about a new message or a finished task after they close it."
+tags: [notifications]
+order: 67
+---
+
+With `puter.notifications`, your app can notify the user it runs for. Each notification shows in the user's Puter notifications panel, and while your app is open it reaches the page right away. Once the user closes your app and Puter, the same notification goes out as a push. The browser shows it on their phone or desktop, and a tap opens your site.
+
+This recipe sets that up for a website on its own domain. An app inside the Puter desktop only needs [`requestPermission()`](/Notifications/requestPermission/). [Push notifications for your app](/Notifications/push/) covers both cases.
+
+## Serve the Service Worker
+
+Pushes arrive on your own domain through a service worker. Put this file at the root of your site, as `/puter-sw.js`:
+
+```js
+importScripts('https://js.puter.com/v2/push-sw.js');
+```
+
+If your site already has a service worker, add that line to it and pass its path as `serviceWorker` in the calls below.
+
+## Handle Pushes
+
+On every page load, say what a push shows with [`puter.notifications.onPush()`](/Notifications/onPush/). The key is the `channel` you send your notifications with:
+
+```js
+await puter.notifications.onPush('chat', (n) => ({
+ title: `${n.app?.name ?? 'Chat'}: ${n.title}`,
+ body: n.text ?? '',
+ url: n.target ?? '/',
+}));
+```
+
+While your app is open, the handler runs in the page. While it isn't, the same handler runs in your service worker, re-created from its source, so it can only use its argument and what's written inside it. It has no `window` and 5 seconds to return. The full list of limits is on [`puter.notifications.onPush()`](/Notifications/onPush/).
+
+## Turn Pushes On
+
+Browsers only show their permission prompt from a user gesture, so call [`puter.notifications.enable()`](/Notifications/enable/) from a click. It asks the browser's permission on your domain, confirms the browser with Puter, and resolves `true` when pushes are on:
+
+```js
+async function turnOnPushes () {
+ try {
+ return await puter.notifications.enable();
+ } catch (error) {
+ if (error.code !== 'push_revoked') throw error;
+ const allowed = await puter.notifications.requestPermission();
+ return allowed && await puter.notifications.enable();
+ }
+}
+
+document.getElementById('enable').addEventListener('click', async () => {
+ const on = await turnOnPushes();
+ console.log(on ? 'Pushes are on' : 'Pushes are off');
+});
+```
+
+The call rejects with `push_revoked` when the user turned your app off in Puter's settings. That choice stands until they allow your app again, which [`puter.notifications.requestPermission()`](/Notifications/requestPermission/) asks for with Puter's own prompt. After that, [`puter.notifications.enable()`](/Notifications/enable/) goes through.
+
+On later visits, [`puter.notifications.onPush()`](/Notifications/onPush/) keeps the browser's registration current without asking again, so the button is only needed once.
+
+## Send a Notification
+
+Send with [`puter.notifications.notify()`](/Notifications/notify/), with the handler's key as `channel`:
+
+```js
+await puter.notifications.notify({
+ title: 'New message from Ana',
+ text: 'See you at 6?',
+ target: '/chats/42',
+ data: { chatId: 42 },
+ channel: 'chat',
+});
+```
+
+It works from the page and from a worker. The notification always shows in the user's Puter notifications panel. It goes out as a push only while the user has neither your app nor Puter open, and a user gets at most one push a minute from each app. A notification over that still shows in the panel. The other limits are on [Rate Limits and Quotas](/rate-limits-and-quotas/#notifications).
+
+Push services can read `data`, so put IDs in it and fetch the rest. For a notification whose text shouldn't leave Puter, set `sensitive: true`, and the push shows a generic line instead.
+
+## Full Code
+
+The worker, at `/puter-sw.js`:
+
+```js
+importScripts('https://js.puter.com/v2/push-sw.js');
+```
+
+The page:
+
+```js
+await puter.notifications.onPush('chat', (n) => ({
+ title: `${n.app?.name ?? 'Chat'}: ${n.title}`,
+ body: n.text ?? '',
+ url: n.target ?? '/',
+}));
+
+async function turnOnPushes () {
+ try {
+ return await puter.notifications.enable();
+ } catch (error) {
+ if (error.code !== 'push_revoked') throw error;
+ const allowed = await puter.notifications.requestPermission();
+ return allowed && await puter.notifications.enable();
+ }
+}
+
+document.getElementById('enable').addEventListener('click', async () => {
+ const on = await turnOnPushes();
+ console.log(on ? 'Pushes are on' : 'Pushes are off');
+});
+
+await puter.notifications.notify({
+ title: 'New message from Ana',
+ text: 'See you at 6?',
+ target: '/chats/42',
+ data: { chatId: 42 },
+ channel: 'chat',
+});
+```
diff --git a/src/docs/src/sidebar.js b/src/docs/src/sidebar.js
index 1f0b7ad0a..a0fcafe88 100755
--- a/src/docs/src/sidebar.js
+++ b/src/docs/src/sidebar.js
@@ -534,6 +534,14 @@ let sidebar = [
source: '/Notifications.md',
path: '/Notifications',
children: [
+ {
+ title: 'Push notifications for your app',
+ page_title: 'Push notifications for your app',
+ title_tag: 'Push notifications for your app',
+ icon: '/assets/img/object.svg',
+ source: '/Notifications/push.md',
+ path: '/Notifications/push',
+ },
{
title: 'notify()',
page_title: 'puter.notifications.notify()',