Cloud|

Notifications

Define, send, and inspect typed notifications.

19 min read Updated 2026-10-20 #notifications#email#browser#do not disturb

Define each notification once. Then send it with typed data.

The definition gives Cloud enough information to validate the event, resolve the recipient, apply notification preferences, choose delivery channels, and record the result.

Cloud stores the event and handles delivery, fallback, retries, and history. The application still decides when the domain event has happened.

A notification does not grant permission. Authorize the domain change before sending it. See Resource authorization.

Notification model

A definition gives the event a stable ID such as inventory.stockLow. It defines:

  • who can receive it;
  • which payload is valid;
  • what the recipient sees;
  • which delivery channels are recommended or required.

The send API accepts the bound definition. It does not accept an arbitrary event name.

The application owns Cloud owns
The domain event and when it has committed Recipient resolution and user preferences
The payload schema and presentation Event and delivery persistence
Whether a channel is recommended or required Channel selection, fallback, and retries
Authorization for the operation that caused the event Delivery history and operational status

A notification reports a domain change. The domain database remains the source of truth.

Define a notification

A definition describes one notification event. Keep definitions in one small application module.

ts
import { notification } from "@k2b/cloud";
import { z } from "zod";

export const NOTIFICATIONS = {
  stockLow: notification({
    recipient: "user",
    label: "Low stock",
    description: "Warns inventory owners when an item falls below its threshold.",
    presentation: {
      baseLocale: "en",
      translations: {
        de: {
          label: "Niedriger Bestand",
          description: "Warnt Verantwortliche, wenn der Bestand eines Artikels den Grenzwert unterschreitet.",
        },
      },
    },
    data: z.object({
      itemId: z.string(),
      itemName: z.string(),
      remaining: z.number().int().nonnegative(),
    }),
    delivery: {
      recommended: ["browser", "email"],
    },
    render: ({ itemId, itemName, remaining }, { locale }) => ({
      title: `${itemName} is running low`,
      body: `${remaining} units remain.`,
      targetHref: `/app/inventory/items/${encodeURIComponent(itemId)}`,
    }),
    email: ({ itemName, remaining }, { locale }) => ({
      subject: `${itemName} is running low`,
      content: `${remaining} units remain.`,
    }),
  }),
};

The Zod schema provides the TypeScript type. Cloud also uses it to validate data at runtime.

Set the definition options

Option Required Contract
recipient Yes "user" or "email"; fixes the address shape used by send()
label Yes Non-empty name used by preference and operations surfaces
description Yes Non-empty explanation of when the application emits the event
presentation No Localized overlays for label and description
data Yes Zod schema used for type inference and runtime parsing
delivery.recommended No Ordered, preference-aware channels; defaults to []
delivery.required No Channels that cannot be disabled; defaults to []
render Yes Builds the channel-neutral presentation
email No Builds an email-specific presentation when email is selected

label and description cannot be empty.

When presentation is present, the complete label and description declaration belongs to presentation.baseLocale. Add partial overlays under presentation.translations. Cloud canonicalizes BCP 47 locale keys and resolves an exact locale, then its language ancestors, then the base declaration. For example, de-CH uses a de overlay when no de-CH overlay exists. Notification preferences and delivery history receive this request-scoped presentation; stable definition IDs and keys do not change.

Channel names cannot be duplicated within a delivery list or appear in both lists. An email-recipient definition must include email in delivery.required.

render and email can also return a Promise.

Render the content

Follow Product language and tone for notification titles, bodies, actions, and email subjects in English and German.

render() receives the parsed payload and a context containing the canonical locale selected for this notification. email() receives the same context. Use it to resolve final text and value formatting without adding locale to the domain payload schema. render() returns:

Field Required Constraint
title Yes Trimmed, non-empty, and at most 200 characters
body No Trimmed and at most 4,000 characters; an empty body is omitted
preview No Browser-only text; normalized and shortened at a grapheme boundary to at most 200 UTF-16 code units, including a final … when shortened; an empty result is omitted
targetHref No Canonical same-origin absolute path beginning with /
group No Stable browser group key: 1–128 characters matching ^[A-Za-z0-9._:-]+$, with no whitespace; grouping requires an application ID that starts with a lowercase letter and contains only lowercase letters, digits, and hyphens
badge No Non-negative safe integer for the app badge; 0 clears it

targetHref must point to a route on the same Cloud origin. External URLs are rejected.

Keep sensitive details on the destination page. That page must check access.

When email() is present, it returns:

Field Required Meaning
subject Yes Email subject
content No Plain-text content
rawHtml No HTML content

Cloud sanitizes the body and wraps it in the installation's HTML frame. The frame keeps quotes (blockquote), preformatted blocks (pre), and horizontal lines (hr); a pre wraps long lines unless its own inline style sets white-space. It also sends a plain-text part: sanitized content when supplied, otherwise text derived from rawHtml. Email goes through Outgoing mail on the default sender profile as app core, with a stable key for each delivery.

Without email(), Cloud uses the neutral title as the subject and body as the plain-text content.

Register the definition

ts
import { defineApp } from "@k2b/cloud";
import { NOTIFICATIONS } from "./notifications";

export const app = defineApp({
  id: "inventory",
  // ...
  notifications: NOTIFICATIONS,
});

Definition keys use lower camel case, such as stockLow. defineApp() combines the application ID and key into inventory.stockLow. The bound, typed definition is available as app.notifications.stockLow.

Cloud registers the metadata when the application starts. Schemas and rendering functions stay inside the application.

Removing a definition makes it inactive after the next registration.

Choose a recipient

The recipient determines the address accepted by send().

Recipient Send with Use for
user { userId } Product notifications for an existing Cloud user
email { email } Invitations or messages for someone without a Cloud account

A user recipient must exist in Cloud. Cloud resolves the user's registered email address and browser endpoints when the event is sent.

A direct email address is normalized and validated before Cloud creates the event.

Email recipients must require the email channel. They have no Cloud account with notification preferences.

Choose delivery channels

The delivery policy determines how Cloud sends the notification.

ts
delivery: {
  recommended: ["browser", "email"],
  required: [],
}
Policy Selection Timing Failure
recommended User preferences replace the ordered defaults The first selected channel is queued; later choices are fallbacks Cloud activates the next choice
required The user cannot disable the channel Every required delivery is processed as part of send() Missing or failed required delivery makes the result an error

Use required delivery only when the channel is part of the protocol. Ordinary product updates should normally be recommended so the recipient controls how they arrive.

A required channel can still be unavailable. Cloud returns an error summary when it has no driver or destination.

Browser delivery

The browser channel uses Web Push exclusively. It needs a user with an active browser endpoint and notification permission. Each registered endpoint gets its own delivery.

The service worker shows an operating-system notification whether Cloud is visible, in the background, or has no open tab. An already-open destination does not suppress the notification. Multiple tabs in the same browser profile do not create extra deliveries. Browser and operating-system settings control when and how notifications appear.

Notifications show the rendered title and Cloud icon, plus preview as the notification text when supplied. The presentation body stays out of the push payload; without preview, no text beyond the title is sent. Clicking a notification opens or focuses its Cloud destination, where normal authentication and authorization apply.

Applications resolve preview in the locale received by render(), selected by send(). Cloud examines only the first 1,000 UTF-16 code units, collapses runs of whitespace and control characters to one space, and trims the result. It shortens long text at a grapheme boundary to at most 200 code units, including a single …; emoji and combining characters remain intact. A grapheme cut by the input bound is omitted. Empty previews are omitted, and long message text is shortened rather than rejected. If the preview would make the encrypted push message larger than 4,096 bytes, the size every push service must accept (RFC 8030), Cloud omits the preview and the notification arrives with its title only.

Preview text is shown by the operating system, including on the lock screen and in notification centres, and may be mirrored to paired devices. It travels through the browser vendor's push service encrypted for the subscription (RFC 8291); the push service sees timing and size, but not the text. Cloud keeps the preview only inside the encrypted delivery payload until the attempt succeeds or fails permanently, never on the event or in history. Only the browser channel receives preview; email and deployment channels never see it.

The preview is fixed when send() runs. Cloud does not check access again before a queued, retried, or fallback browser delivery. After the push service accepts a push, Cloud cannot recall or change it. An offline device receives it when it reconnects, up to 24 hours later. Decide as late as possible before send() whether the recipient may read the content, and offer operators a setting to turn previews off where content is sensitive.

Set group in render() to replace notifications for the same subject on each device. Cloud prefixes the key with the application's ID: inventory and group: "stock:item-42" use the tag inventory:stock:item-42. Other applications cannot collide with that tag. Each new notification in the group replaces the previous one and requests a fresh alert with renotify: true; the browser and operating system control the alert. The push topic stays per event, so the push service cannot link pushes by group and an offline device receives each push when it reconnects. If an older notification arrives after a newer one in the same group, for example after a delivery retry, the device keeps the newer notification without a new alert. Without group, notifications keep their event ID as the tag.

Set badge to the application's current unread count. A positive count calls the Badging API; 0 clears the badge. Omitting it leaves the badge unchanged. The device applies a badge only when it was rendered at or after the last badge it applied. Badge support is optional: unsupported browsers and rejected badge requests silently leave it absent or unchanged, and the notification still appears. The badge belongs to the installed Cloud application, so applications that set it must decide which count to use. Email ignores group and badge. None of group, badge, and preview is stored on the notification event. The presentation body still stays out of the browser payload; applications opt into notification text through preview.

Without an active endpoint, Cloud records no_endpoint for that browser delivery. A later configured recommended channel can still receive the event. There is no in-app notification card or separate notification WebSocket. Ordinary interface feedback, such as a saved confirmation, is unaffected.

Use the browser client to read and change the current browser's registration:

ts
import { browserNotificationClient } from "@k2b/cloud/browser/notifications";

const initial = await browserNotificationClient.refreshExisting();

enableNotificationsButton.addEventListener("click", async () => {
  const state = await browserNotificationClient.enable();
  console.log(state.enabled);
});

refreshExisting() registers the Cloud service worker and reconnects an existing subscription. It never asks for permission. Call enable() only from an explicit user action because it may open the browser permission prompt.

Use state() to inspect support, permission, and subscription state. Use disable() to disable the endpoint and unsubscribe this browser. Before sending a queued browser delivery, Cloud checks that its endpoint is still active. Disabling or rebinding that endpoint prevents later attempts from sending to it.

After reading a group's content, close its notifications on the current device and update the badge from the open tab:

ts
import { browserNotificationClient } from "@k2b/cloud/browser/notifications";

await browserNotificationClient.closeGroup("inventory", "stock:item-42");
await browserNotificationClient.setBadge(remainingUnreadCount);
// Use setBadge(0) to clear the badge.

closeGroup(appId, group) closes only notifications with that application's group tag on the existing Cloud service-worker registration (scope /). It never registers a worker or prompts for permission. Unsupported browsers, missing registrations, and invalid inputs silently do nothing. setBadge(count) also silently does nothing when unsupported, refused, or given a count that is not a non-negative safe integer.

Closing a group affects only the reading device. Web Push cannot reliably close notifications on other devices, and closing notifications does not change the badge automatically.

Browser delivery requires a secure context, service-worker and Push API support. On iPhone and iPad, Cloud must run as an installed Home Screen application.

Do not disturb and quiet hours

Each person can hold back browser notifications from every application under My account → Notifications:

  • Do not disturb pauses them until an instant the person chooses, at most 366 days ahead. To stop browser notifications for longer, turn them off in the preferences or on the device.
  • Quiet hours repeat every week: up to seven periods, each with weekdays and a HH:MM start and end in one IANA time zone the person chooses.

A period belongs to the day it starts on. When the end is at or before the start, the period runs into the next day, so 19:00–07:00 on Friday keeps Saturday morning quiet. An equal start and end mean 24 hours, so 00:00–00:00 is the whole day. Cloud converts the wall-clock times for each date in the chosen time zone. A period keeps its local times across daylight-saving changes, and a time inside a skipped hour moves forward with the clock. In the hour that repeats when daylight-saving time ends, a start takes the first occurrence and an end the second, so 01:00–02:30 stays quiet until the second 02:30.

The delivery worker checks quiet time when a recommended browser delivery would go out, including retries and fallbacks. During quiet time Cloud drops the push instead of postponing it, so nothing arrives in a burst when quiet time ends. The delivery becomes suppressed with do_not_disturb or quiet_hours, and its waiting fallbacks end with the same code instead of taking over: a person in quiet hours gets no email in place of the push. Applications need no code for this. Quiet time does not change what they store, so activity lists and unread counts keep updating.

Quiet time leaves the other channels alone. Email that is the first recommended choice, deployment channels, and required deliveries of every channel, which belong to a protocol such as sign-in, still go out. A recommended security notice that goes by browser, because the person has no email address or put the browser first, is held back like any other and stays in notification history.

Read the signed-in person's current state for display, for example a moon icon in a sidebar header:

ts
import { browserNotificationClient } from "@k2b/cloud/browser/notifications";

const quiet = await browserNotificationClient.quietState();
// { active: true, reason: "doNotDisturb", until: "2026-10-10T06:00:00.000Z", nextStart: null }

reason is doNotDisturb while that pause lasts and quietHours otherwise. until is the end of the whole quiet time, including quiet hours that continue when a pause ends. nextStart is the start of the next quiet hours while inactive. Either is null when nothing changes within the next week. Load the state again once that instant has passed. quietState() rejects when the request fails. Server code reads the same state with notifications.user.quiet.get(userId) from @k2b/cloud/services. Delivery applies quiet time on its own; do not filter sends by this state.

Email delivery

Core delivers email through the default outgoing mail profile. Configure and test that sender before enabling email notifications.

Email delivery is available when the resolved recipient has an address:

  • a direct email recipient supplies it in the send call;
  • a user recipient uses the email address stored on the Cloud user.

If a user has no email address, Cloud records no_endpoint for that email delivery.

An email() renderer can override the neutral presentation. Without it, Cloud uses the notification title and body.

Deployment channels

Channel drivers belong to the deployment. They do not belong to an application.

A deployment package can add typed channels. Applications can then use those channels in their delivery policy.

Extend the channel registry, then register the driver during deployment startup:

ts
import {
  registerNotificationChannel,
  type NotificationChannelDriver,
} from "@k2b/cloud/services";

declare module "@k2b/cloud/contracts/notifications" {
  interface NotificationChannelRegistry {
    sms: true;
  }
}

const smsDriver: NotificationChannelDriver = {
  id: "sms",
  async resolveDestinations(recipient) {
    const phone = await resolvePhoneNumber(recipient);
    return phone
      ? [{ key: phone, label: "SMS", context: { phone } }]
      : [];
  },
  createPayload({ presentation, destination }) {
    return {
      phone: (destination.context as { phone: string }).phone,
      text: [presentation.title, presentation.body].filter(Boolean).join("\n"),
    };
  },
  async deliver(payload) {
    await smsProvider.send(payload as { phone: string; text: string });
  },
};

const unregisterSms = registerNotificationChannel(smsDriver);

A driver resolves destinations, builds a persisted provider payload, and delivers that payload. Drivers receive the presentation without preview, which Cloud reserves for browser notifications. deliver(payload, context) receives an optional second argument with deliveryId and, during worker processing, an abort signal. For an email recovery, it also includes the persisted outgoingMailId. Existing drivers may keep returning void to indicate delivery. A driver that hands work to a durable provider may return { status: "pending", retryAfterMs, errorMessage? } with a finite retryAfterMs greater than zero. Cloud clamps that delay to 2,000–300,000 ms, persists the pending state with no error code, preserves the notification attempt budget, and schedules another call. A pending return without a valid delay is treated as delivered, like void. An optional outgoingMailId is recorded only when it is a UUID string. A completed driver may return { status: "delivered" }.

Channel IDs are lowercase identifiers with at most 80 characters. Register one driver per ID. Keep the returned cleanup function and call it when the deployment integration stops.

Core's Pending approvals notification shows the newest waiting request's plain Assistant question and reason in the question's language, the same language as its approval card. Email adds the link to the approvals page. Existing events without those fields keep the general notice. See Pending approvals notification for eligibility and bundling.

Send a notification

Send after the domain change commits. Use the bound definition from defineApp().

Build the idempotency key from the domain change.

ts
import { notifications } from "@k2b/cloud/services";
import { getLocale } from "@k2b/cloud/server";
import { app } from "./config";

const result = await notifications.send(app.notifications.stockLow, {
  recipient: { userId: ownerId },
  data: {
    itemId,
    itemName,
    remaining,
  },
  idempotencyKey: `stock-low:${itemId}:${thresholdVersion}`,
  locale: getLocale(c),
});

Set the send options

Option Required Meaning
recipient Yes { userId } or { email }, fixed by the definition
data Yes Payload parsed with the definition's Zod schema
idempotencyKey Yes Stable identity for this logical event
sentBy No Cloud user ID attributed as the sender
locale No Intended locale for render and email; canonicalized and defaults to en

Omit sentBy for a system-generated notification. When present, it must be the ID of an existing Cloud user. Arbitrary actor IDs and process names are not valid.

At a request seam, pass getLocale(c). A background sender must pass the locale persisted with its work or deliberately use the operator's app.locale setting. Locale is delivery metadata, not part of data or the idempotency identity.

Deduplicate retries

Cloud trims idempotencyKey and accepts from 1 to 300 characters. Event identity consists of:

  • the bound notification definition;
  • the resolved recipient;
  • the idempotency key.

Sending the same combination returns the existing event. It does not create a duplicate.

Use an order ID, resource version, or committed transition ID. Do not use the current timestamp.

Cloud owns provider retries. Calling send() again does not restart them.

Send after commit

The application remains the source of truth for the event. Persist the stock change, export result, invitation, or other domain state first. Send the notification after the transaction commits.

If the application recovers from a crash between those operations, it can call send() again with the same idempotency key. Cloud returns the existing event when the first call already created it.

Handle send errors

notifications.send() rejects when it cannot form a valid event. Validation failures before event creation include:

  • an empty or overlong idempotency key;
  • payload data rejected by the Zod schema;
  • an error from render();
  • an empty or overlong title, overlong body, unsafe targetHref, invalid group, or invalid badge;
  • a user ID that does not exist;
  • an invalid direct email address.

Storage and catalog failures also reject the call.

An error from the optional email() renderer occurs while Cloud prepares the email delivery. It appears as a failed delivery with preparation_failed.

After Cloud creates the event, delivery problems appear in the result. A missing required channel returns an error summary.

Required channels wait

Required deliveries are attempted before send() returns. Recommended deliveries are queued.

A route that requires a channel therefore includes its initial delivery attempt in request latency.

Read the result

notifications.send() returns one event summary and an entry for every persisted channel delivery.

ts
type TypedNotificationSendResult = {
  id: string;
  created: boolean;
  status: "queued" | "delivered" | "suppressed" | "error";
  deliveries: Array<{
    id: string;
    channel: string;
    required: boolean;
    status:
      | "deferred"
      | "pending"
      | "sending"
      | "delivered"
      | "suppressed"
      | "failed";
    errorCode: string | null;
  }>;
};

created is false when the event already existed.

Read the event status

Status Meaning
queued At least one persisted delivery is pending or sending
delivered At least one persisted delivery completed and no required delivery has an error
suppressed No persisted delivery is pending, sending, or delivered
error A required delivery was suppressed, failed, or has an error code

Delivery status describes provider processing. delivered confirms provider acceptance, not that the operating system displayed a notification or that the recipient read it.

Read each delivery

Status Meaning
deferred A later recommended fallback is waiting for earlier choices
pending The delivery is ready for a worker or a scheduled retry
sending A worker owns the current attempt
delivered The channel provider accepted the delivery
suppressed Cloud intentionally did not attempt this destination, for example during the recipient's quiet time
failed Delivery ended without another retry

For recommended channels, Cloud queues the first selected choice. A successful delivery suppresses later choices as fallback_not_needed. A terminal failure activates the next deferred choice.

Required channels do not use this fallback chain. Cloud attempts every required delivery.

Read error codes

The typed send result exposes the current delivery error code. Built-in platform codes include:

Code Meaning
disabled_by_user The user disabled every recommended channel
no_preferred_channel No recommended channel or user preference exists
channel_unavailable No driver is registered for the selected channel
no_endpoint The recipient has no usable destination for the channel
preparation_failed Destination resolution or payload creation failed
fallback_not_needed An earlier recommended channel delivered the event
payload_missing A persisted delivery has no usable encrypted payload
lease_recovered Cloud recovered an interrupted delivery attempt
endpoint_gone A browser endpoint no longer exists
provider_rejected A browser provider rejected a non-retryable request
do_not_disturb The recipient paused browser notifications; the event is not delivered later
quiet_hours The recipient's quiet hours held back the browser notification
provider_error A provider failed without a more specific public code

Custom channel drivers may add codes. Branch on status first. Use error codes for diagnostics.

User-facing history normalizes unknown provider-specific errors to provider_error.

Delivery retries

Cloud retries retryable provider failures with backoff for up to five delivery attempts. Non-retryable failures move directly to failed.

Temporary SMTP failures keep email deliveries pending while outgoing mail retries them. Waiting for accepted mail does not use notification attempts and keeps the delivery error code null, so a required email reports queued. The last SMTP answer may remain in the delivery error message for operators. Other retryable channel failures keep their error codes, so required deliveries in retry continue to report error. Recommended fallback channels activate only after a terminal mail failure, which can take up to the 24-hour delivery deadline. They stay deferred while SMTP retries continue, so a recovered SMTP server does not cause delivery through both channels. The outgoing-mail record settles within its 24-hour deadline. Permanent SMTP failures and mail cancellation end the notification delivery; mail availability failures retry, while profile and input policy errors fail immediately.

Delivery observability includes outgoingMailId and outgoingMailStatus, so operators can follow the email in the send log, including a later bounced status. The ID remains after mail record retention; its status then becomes null. Observability → Notifications (/admin/observability/notifications) shows the mail status under the email channel, and the Accounts batch detail shows it under each recipient's status, whenever it adds information, such as queued, failed, or bounced. The mail ID appears as the line's tooltip.

The delivery runtime also recovers an attempt left in sending after a worker stops. It returns the delivery to pending and records lease_recovered.

PostgreSQL retains delivery state and retry times. Core checks for due work at startup and every 30 seconds. An interrupted sending attempt becomes eligible for recovery after five minutes.

Calling notifications.send() again with the same idempotency key is safe, but it does not restart provider delivery. The existing event and current delivery state are returned. Cloud's delivery worker owns retries and recovery.

Notification batches

Notification batches enqueue chunks of up to 100 recipients into outgoing mail's bulk lane as app core. The default profile's pace and daily recipient limit for app core apply. Batches continue as the rolling 24-hour window frees capacity. Core's magic links, password resets, and other notification email share this limit: leave headroom or keep the default profile unlimited. Recipients remain sending until their mail settles; batch counters and message history then record sent (also for bounced) or error for failed or cancelled mail.

Worker retries and stale claims reuse the same send generation and mail ID. An explicit failed-recipient retry creates a new generation. If retention removed a mail record before reconciliation, recovery records an error instead of automatically sending it again. Temporary backlog, quota, or availability failures leave recipients pending for another attempt; profile and input policy errors mark them as errors.

Connect a coding agent

The Cloud skill provides compact working instructions. MCP supplies exact current documentation when details matter.

CLI command
bunx skills add https://docs.example.com

This command installs the working instructions published by this website in the selected coding agent.

For exact, current details, also connect the MCP server through one of the agent tabs.

Installation uses the open-source Vercel Skills CLI.

The skill and MCP complement each other: the skill describes workflows, while MCP supplies current documentation.