Web Push notifications
OpenCloud apps can send native Web Push notifications to a signed-in user's subscribed browsers. Selecting a notification focuses an existing app window or opens the same-origin path supplied by the app. OpenCloud owns VAPID keys, push-provider delivery, retries, encrypted subscription storage, and its narrowly scoped service worker, so an app does not need OneSignal or another notification broker.
Declare the capability
Web Push requires browser and server SDK 2.1.0 or later:
runtime:
sdk:
version: 2.2.0
notifications:
webPush: true
icon: /icons/notification.png
functions:
- name: send-notification
entrypoint: functions/send-notification/index.ts
access: usernotifications.icon is the app-wide notification icon. It must be a same-origin absolute path. If it is omitted, OpenCloud uses its own logo. This setting is separate from the icons array in the browser web app manifest, which identifies the installed app to the operating system.
Desktop browsers and Android may render the manifest fallback or per-message notification icon. iOS and iPadOS currently ignore the Notifications API icon option and show the installed Home Screen app icon instead; WebKit's tracking issue remains open. Provide that installed icon through the web app manifest, or through an apple-touch-icon link when an Apple-specific variant is intentional. WebKit documents that apple-touch-icon takes precedence when both are present.
For an installable mobile experience, also serve a normal web app manifest and link it from frontend/index.html:
<link rel="manifest" href="/manifest.webmanifest" />The manifest should provide a name, start_url, display, theme colors, and appropriate icons. On iOS and iPadOS, Web Push requires Safari 16.4 or later and a web app installed to the Home Screen. Chrome and other compatible browsers use the same application API.
Let the user opt in
First inspect the state without showing a permission prompt:
import { opencloud } from "/_opencloud/sdk.js";
const state = await opencloud.notifications.status();
renderNotificationState(state.state);Call subscribe() directly inside a visible click or tap handler. Do not ask on page load or after an unrelated asynchronous workflow:
enableButton.addEventListener("click", async () => {
const state = await opencloud.notifications.subscribe();
renderNotificationState(state.state);
});
disableButton.addEventListener("click", async () => {
const state = await opencloud.notifications.unsubscribe();
renderNotificationState(state.state);
});The state is one of unsupported, prompt, denied, unsubscribed, or subscribed. Permission is controlled by the browser or operating system; application code cannot reset a denial.
Subscriptions are bound to the exact OpenCloud browser session that created them. Signing out or revoking that session makes it ineligible for future delivery. A user can subscribe several browsers or devices independently.
Send from a Function
Only a Function can send. Target an authenticated app user ID and use a stable idempotency key for the logical notification:
import { defineFunction, errors, schema } from "@opencloud/server";
export default defineFunction({
input: schema.object({
title: schema.string({ minLength: 1, maxLength: 120 }),
body: schema.optional(schema.string({ maxLength: 1_000 })),
path: schema.optional(schema.string({ minLength: 1, maxLength: 2_048 })),
icon: schema.optional(schema.string({ minLength: 1, maxLength: 2_048 })),
}),
handler: ({ input, notifications, requestId, user }) => {
if (!user) {
throw errors.unauthorized("SIGN_IN_REQUIRED", "Please sign in");
}
return notifications.send(
{
userId: user.id,
title: input.title,
body: input.body,
path: input.path ?? "/",
icon: input.icon,
},
{ idempotencyKey: `notification:${requestId}` },
);
},
});path and icon must begin with one / and remain on the app origin. Per-message icon overrides notifications.icon; if both are omitted, the managed OpenCloud logo is used. A square PNG with a transparent background is the most portable choice. OpenCloud resolves custom icons from the exact deployment and gives the browser a signed, image-only URL, so an icon can load after sign-out without making the rest of a private app public. A missing or invalid image falls back to the OpenCloud logo. This resolution order describes the payload OpenCloud sends; iOS and iPadOS still present the installed app icon as described above. The result reports queued, captured, or no_subscribers initially, plus recipient and delivery counts. Provider delivery is asynchronous and may be retried. Push subscriptions that a provider reports as expired are revoked automatically. The complete visible payload and click path must fit within 3,072 UTF-8 bytes.
Validate in development
Development sends never contact Apple, Google, Mozilla, or Microsoft. Invoke the Function, then inspect list_dev_notification_captures; each capture shows the synthetic user, visible title/body, resolved icon, click path, and timestamp. Captures are isolated to the development session and deleted with it.
This verifies the manifest, Function SDK, credential boundary, payload, and click path. A complete acceptance check still needs a real subscribed browser. For iOS, open the HTTPS app in Safari, add it to the Home Screen, launch that installed app, opt in from its button, send a production notification, and confirm that selecting it opens the expected app path.
Security boundary
App code never receives the VAPID private key, raw subscription endpoint, provider credentials, or a general service worker scope. OpenCloud encrypts subscription material and notification payloads at rest, accepts only known browser push-provider hosts, rate-limits sends, and always shows a visible notification. The platform worker is scoped to /_opencloud/; it cannot intercept the app's normal pages or asset requests.
