Web Push Experimental

wp.rt.ht and push.rt.ht serve one app, Web Push. You sign in, subscribe your devices to topics, and then send notifications to yourself from any page or worker, with one HTTP call or a small script. The browser's push service delivers them, and the app's service worker shows them. The two hosts run the same app: wp.rt.ht needs a topic for each notification, and push.rt.ht falls back to the default topic, RTEdge.net.

Note: Web Push is experimental. The app, its API and the client library may change.

The app

Open https://wp.rt.ht/ and sign in. The app uses ::si, so its sessions are per host: sign in on push.rt.ht separately. It is an installable app titled Web Push, with five parts:

Topics

Choose or create a topic, subscribe or unsubscribe this device, and see a table of topics with their subscriber counts. A topic name is trimmed and must not be blank, . or .., or contain ●. The default topic is RTEdge.net.

Devices

The devices subscribed to your topics, labeled by push service: Chrome, Firefox or Safari. You can give a device a name.

Badges

The app-icon badge count of each device. See App badges.

Compose

Editable previews for Chrome, Safari and Firefox, an app-icon badge field and a Send button.

Delivery results

What each push service answered for the notification that you sent.

Install the app on your Home Screen, in Safari's Dock, or in Chrome or Edge. In Safari, allow notifications. Your device's settings control whether badges are visible.

How a device subscribes

The app uses one of two paths, and reports the stage that failed (permission, worker, subscription, registration or saving) if a browser cannot finish.

  • Declarative push. When the page is top-level and window.pushManager exists, as in Safari 18.4 and later, the app subscribes straight from the button click, with no service worker.
  • Classic push. Otherwise it asks for notification permission, registers service-worker.js at the app's scope, waits up to 15 seconds for it to activate, and subscribes through registration.pushManager.

Browser support

The versions come from the MDN browser compatibility data.

FeatureChrome and EdgeFirefoxSafari
Push (PushManager)Chrome 42, Edge 17Firefox 44, Android 48Safari 16, with notifications on macOS Ventura and later. iOS 16.4, in web apps saved to the Home Screen only.
Declarative push (window.pushManager)NoNoSafari 18.4 and iOS 18.4. On iOS, in Home Screen apps only.
App badges (setAppBadge)Chrome and Edge 81 on Windows and macOS, ChromeOS 91. Not on Linux.NoSafari 17 in installed web apps on macOS Sonoma and later. iOS 16.4 in Home Screen apps.

On iOS, Notification is undefined unless the page is a Home Screen web app whose manifest sets a display other than the default. Chrome on Android has no badges.

Send a notification

Send a POST to the :notify worker path of either host. The recipient is named with query parameters, and the body describes the notification.

plain
POST https://wp.rt.ht/:notify?user=you@example.com&topic=deploys
POST https://push.rt.ht/:notify?user=you@example.com

The request names its recipient with user and topic. A notification reaches the devices that the recipient subscribed to that topic. Subscribing needs a sign-in on the host.

Parameters

user

The recipient's email address. It defaults to the signed-in user.

topic or t

The topic. wp.rt.ht requires it, and push.rt.ht defaults to RTEdge.net.

truncate

0 or 1. Per-device truncation is on by default. See Delivery.

Body

The body is JSON: either a notification object, or an array [title, options]. A body that is not valid JSON is still sent, as a notification titled "❌ Notify" that carries the parser error.

title

The title. It defaults to Push.

body

The text of the notification.

navigate

The URL that opens when the notification is clicked. It defaults to the app's URL.

actions

An array of { action, title, navigate?, icon? }. You can also write data.actions[name] = ["openWindow", url]. Actions without a target that can be resolved are dropped.

app_badge

The app-icon badge count. See App badges.

Other fields

lang, tag, dir (auto, ltr or rtl), icon, image, badge (URLs are resolved against the app), silent, renotify, requireInteraction, timestamp, vibrate and data.

Response

The answer is a map from each push endpoint to its result:

js
{ "<endpoint>": { success, status, response: { ok, status, headers, body, json /* or text */ }, fit, appBadge } }
  • With no subscriber, the answer is { "error": "no subscriptions" }.
  • The encoded JSON of a notification can be up to 4076 bytes. A larger one answers 413 with { error, bytes, limit }.
  • fit reports what per-device truncation did.

Delivery

Each notification is sent as a Declarative Web Push message, with web_push: 8030 and mutable: false, authenticated with VAPID, with TTL: 60 and Urgency: high. Per-device truncation clips the title, the body lines and the action titles to what Windows, iOS, Safari, macOS, Android, Linux and ChromeOS show. Pass truncate=0 to turn it off.

App badges

app_badge controls the badge on the app icon:

  • Omit it for an automatic increase of 1 on each device.
  • null leaves the badge unchanged.
  • A non-negative safe integer, or a decimal string of up to 20 digits that is at most 18446744073709551615, sets the count.

POST /:badge with exactly { device } resets the automatic count to 0. It needs a sign-in and a same-origin request, and the device must be subscribed for that user.

From a page

Subscribe a device to the topic in the app first. The button then sends a notification to yourself:

html
<button id=ping>Notify me</button>
<script type=module>
  const user = 'you@example.com', topic = 'deploys';
  ping.onclick = () => fetch(`https://wp.rt.ht/:notify?${new URLSearchParams({ user, topic })}`, {
    method: 'POST', headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ title: 'Deployed', body: 'new.rt.ht is live', navigate: 'https://new.rt.ht/' })
  }).then(r => r.json()).then(console.log);
</script>

From a worker

A worker sends the same POST with fetch(). Use an absolute URL. An edge worker that fetches another *.rt.ht deployment directly can get a 522, so go through the in.rt.ht gateway instead: https://in.rt.ht/wp/:notify?user=…&topic=…. In the playground, a worker's fetch() is a browser fetch, so CORS applies, and the app answers with the request's origin. Playground and edge differences explains the gateway.

The client library

A script tag from the app defines a global function, push. The attributes name the recipient and the topic:

plain
<script src=//wp.rt.ht/.js user=you@example.com topic=deploys></script>
push(message, options)

Posts to :notify with credentials, and resolves to the JSON result.

push.beacon(message, options)

Sends with navigator.sendBeacon.

push.true(message, options)

Uses a beacon or fetch().

push.init({ user, topic, config })

Sets the recipient, the topic and the configuration. A boolean push attribute on the script runs it on load.

push.ready

Fetches :config.json.

push.load()

When the page is top-level, sends a notification that reads "🚀" followed by the page URL, with the referrer.

push.errors()

Reports window errors.

Warning: Notifications that the library sends carry the page's referrer and the sender's IP address. The notification shows lines for both, and an Edge action that links to an IP lookup, so the recipient sees the sender's IP address. The titles of push.load() notifications also carry the platform, the vendor and the CPU count.

Send when the page loads

html
<script src=//wp.rt.ht/.js user=you@example.com topic=deploys></script>
<script>addEventListener('load', () => push({ title: 'Hello', body: 'from my page' }))</script>

The API

The app's worker answers these paths. Call them in the : form, as in https://wp.rt.ht/:config.json. An unknown path answers 404 with {"error":"not found"}.

PathMethodsWhat it does
/:config.jsonGETReturns { route, publicKey, defaultTopic }: the host's route (push or wp), the app's VAPID public key as base64url, and the default topic RTEdge.net.
/:manifest.jsonGETReturns the web app manifest.
/:vapid.jsonGETReturns a freshly generated P-256 VAPID key pair for your own use, as { P, p }: the raw public key and the PKCS#8 private key, both base64url. Nothing is stored.
/:topicsGET, DELETEGET lists [{ topic, count, subscribed, created }], sorted by name, where subscribed answers for ?device=<id>. DELETE ?topic=<t> answers { message: "Topic deleted", topic, changed }.
/:subscribeGET, POST, PATCH, DELETELists, creates, renames and removes subscriptions. Also reachable as /:subscriptions and /:subscriptions.json. See Subscriptions.
/:notifyPOSTSends a notification. Also reachable as /:notify.json.
/:badgePOSTResets a device's automatic badge count.
/:<N>.pngGETReturns the app icon resized to N by N pixels.

Topics and subscriptions need a sign-in on the host: without one the app answers 401, "sign in to manage …". Acting for another user answers 403. Responses echo the request's Origin with Access-Control-Allow-Credentials: true and Vary: Origin. The preflight for the API paths allows GET, POST, PATCH, DELETE, OPTIONS and the content-type header.

Subscriptions

POST /:subscribe?topic=<t>

Subscribes a device. t= also works. The body is { subscription: <PushSubscription JSON>, metadata?: { userAgent, userAgentData? } }. The endpoint must be an https: URL with keys.p256dh and keys.auth. A stored subscription with different keys or another app answers 409. metadata.userAgent is at most 2048 characters. The app immediately pushes a confirmation, "You are now subscribed!", and answers { message: "Subscribed", changed, push }.

GET /:subscribe?topic=<t>

Lists { endpoint, expirationTime, keys }. With view=devices it lists devices as { id, provider, app, current, name, metadata, created }. The id is the SHA-256 hash of the push endpoint, in hex, and provider is the host of the push service.

PATCH /:subscribe

With { device, name } it renames a device. A name is at most 80 characters, is shared across topics, and an empty name clears it.

DELETE /:subscribe

With { device } it unsubscribes silently. With { subscription } it also pushes "You are now unsubscribed!". Both answer { message: "Unsubscribed", changed }.

See also