# Notifications

> In-app notifications with per-recipient state, subject subscriptions, channel preferences, email and push deliveries, and realtime updates on a private topic.

Source: https://bettersupabase.com/docs/blocks/notifications

The `notifications` [SQL module](/docs/blocks/sql) stores one event per
change and one row per recipient, with read, dismissed and resolved state
each user controls. `createNotifications` from `better-supabase/blocks/notifications`
sends and reads them with typed data, and `useNotifications` from
`better-supabase/blocks/notifications/react` keeps a list current over Supabase Realtime.

```bash
pnpm better-supabase sql add notifications
```

Sending goes through `notify(jsonb)`, a `security definer` function, so a
client can't write notification rows directly or pick another user as the
actor. With the [access contract](/docs/blocks/access) installed, the sender
needs `notifications.send` in the organization and every recipient needs
`notifications.read`; the actor and non-members are left out. With the
[outbox](/docs/blocks/outbox), every notification also emits
`notification.created`.

## Sending [#sending]

Declare each type with a Standard Schema for its data. `send` validates the
data before it reaches the database:

```ts title="lib/notifications.ts"
import "server-only";

import {
  createNotifications,
  sqlTransport,
} from "better-supabase/blocks/notifications";
import { z } from "zod";

export const types = {
  "task.assigned": z.object({ title: z.string() }),
  "approval.requested": z.object({ amount: z.number() }),
};

export const notificationsFor = (claims: Record<string, unknown>) =>
  createNotifications({
    transport: sqlTransport(postgres.asUser(claims)),
    types,
    actionable: ["approval.requested"],
    events: betterSupabase.events,
    render: (item) => ({ title: `${item.type}: ${item.subject?.label ?? ""}` }),
  });
```

```ts
const ctx = await bs.context();
const notifications = notificationsFor(ctx.auth.claims);

await notifications
  .send("task.assigned", {
    tenant: organizationId,
    recipients: [assigneeId],
    subject: { type: "task", id: task.id, label: task.title },
    actionPath: `/tasks/${task.id}`,
    data: { title: task.title },
    key: `task-assigned-${task.id}-${assigneeId}`,
  })
  .orThrow();
```

`send` returns `{ id, recipients }`, the notification id and the user ids
that hold it after the watchers, the audience hook, preferences and the read
filter are applied, or `null` when nobody was left to notify. A composer
can report how many people it reached from `recipients.length`, and
`onSent` and the `notification.created` block event get the same list.
With a `key`, sending again in the same organization returns the first id,
so a retried request never notifies twice. In SQL, `send_notification(jsonb)`
returns the same `{ "id", "recipients" }` object and `notify(jsonb)` returns
only the id. `rpcTransport(supabase)` calls the
same functions over the Data API instead, when the block schema is exposed.

| Input          | Meaning                                                                                                                                   |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `recipients`   | User ids. Subject watchers and the `notification_audience` hook add more                                                                  |
| `subject`      | What it is about, `{ type, id, label? }`. Watchers of the subject get it too                                                              |
| `activity`     | `participating` (default) reaches every watcher; `all` reaches only watchers at level `all`                                               |
| `priority`     | One of `options.priorities` (`low`, `normal`, `high`, `urgent`). Defaults to `normal`                                                     |
| `channels`     | The channels to deliver on. Defaults to `options.channels`                                                                                |
| `includeActor` | Also notify the user who caused it                                                                                                        |
| `watchers`     | `false` leaves the subject's watchers out, for a composer that reaches named people only (a mention, an assignment); `ignore` still holds |
| `exclude`      | User ids left out after the watchers and the hook are added, such as the members a separate mention already reached                       |
| `resolved`     | Store it already resolved, for a record of something that needs no action                                                                 |
| `actorId`      | The actor when the service sends for a user. A signed-in sender is always the actor themselves                                            |

## Reading [#reading]

The same client reads the signed-in user's notifications. `render` runs at
read time, so text follows the reader's locale and later copy changes:

```ts
const items = await notifications
  .list({ tenant: organizationId, status: "unread", locale: "nl" })
  .orThrow();
const { unread, actionable } = await notifications
  .counts({ tenant: organizationId })
  .orThrow();
const one = await notifications.get(items[0].id).orThrow();
const { count, items: changed } = await notifications
  .markRead({ ids: [items[0].id] })
  .orThrow();
await notifications.dismiss([items[0].id]);
await notifications.resolve({
  type: "approval.requested",
  subject: { type: "invoice", id },
});
```

`get(id, { locale?, include? })` reads one of the caller's notifications by
its recipient row id, dismissed ones included, rendered like a list item, or
`null` when the caller has no such notification. It calls
`get_notification(id)`.

`get`, `list` and `page` parse each item's `data` with the schema of its type
in `types`, so `item.data` narrows on `item.type`: after checking
`item.type === "approval.requested"`, `item.data.amount` is a `number`. Data
the schema rejects makes the read fail with a `validation` error and the hint
`NOTIFICATION_DATA_INVALID`; a type that isn't in `types` keeps its data as
stored. `NotificationOf<typeof types>` is that item type.

`markRead`, `markUnread`, `dismiss` and `resolve` return `{ count, items }`:
how many rows changed, and the caller's own notifications as they are after
the change, so an app can update its state without reading them back. For
`resolve`, `count` covers every recipient and `items` holds only the
caller's own copies, which is empty for the service. The SQL functions
return the same object as `jsonb`, with each item in the shape that
`list_notifications` returns.

`list` pages with `cursor` and `limit` (50 by default, at most 200). Pass the
last item of the previous page as `cursor`: the block orders by `created_at`
and then `id`, so items created at the same instant are not skipped. It filters with `status` (`all`, `unread`, `read`, `unresolved`, `settled`),
`read`, `resolved`, `dismissed`, `types`, `subjectTypes` and `search`. `resolve` marks every recipient's copy of a type about one subject
as handled, for example when someone approves the invoice.

`search` matches text in the summary, the subject label and the type,
ignoring case; `%` and `_` in it are plain characters. `subjectTypes` keeps
notifications about subjects of the listed types. Every status except
`settled` leaves dismissed notifications out, and `settled` lists the
resolved and the dismissed ones, for an archive view.

`read`, `resolved` and `dismissed` are independent booleans that combine
with each other and with `status`. `read: false` keeps unread ones, and
`resolved: true` keeps resolved ones. `dismissed` defaults to `false`;
`true` lists only dismissed notifications and `null` lists both. `settled`
keeps its meaning and ignores `dismissed`.

| View              | Filters                            |
| ----------------- | ---------------------------------- |
| Unread and open   | `{ read: false, resolved: false }` |
| Handled           | `{ resolved: true }`               |
| Dismissed         | `{ dismissed: true }`              |
| Everything        | `{ dismissed: null }`              |
| Archive (settled) | `{ status: "settled" }`            |

Without a `resolved_at` column, `resolved: true` matches nothing and
`resolved: false` matches everything.

### Pages with a total [#pages-with-a-total]

An inbox with numbered pages uses `page`, which takes the same filters with
an `offset` instead of `before` and returns the matching total next to the
items:

```ts
const { items, total } = await notifications
  .page({ tenant: organizationId, search: "invoice", limit: 20, offset: 40 })
  .orThrow();
```

It calls `notification_page(tenant, status, types, subject_types, search,
max_items, skip, read, resolved, dismissed)`, which counts the matches in
the same query. `list_notifications` takes the same three filters after
`search`. `list` stays
the cheaper choice for an infinite scroll.

### Unread again and counts [#unread-again-and-counts]

`markUnread({ ids, tenant? })` clears the read time of the caller's own
notifications, so a reader can keep one for later. It returns the count and
the changed notifications and leaves dismissed notifications alone.

`counts()` returns `unread`, `actionable` (unresolved notifications of the
`actionable` types) and `actionableSubjects`, which counts each subject
once. Two reminders about the same invoice count as two in `actionable` and
as one in `actionableSubjects`, which suits a badge that counts open tasks.

### Hydrating a page [#hydrating-a-page]

`render` runs once per item. When it needs data from elsewhere, such as actor
names or subject titles, `hydrate` loads it for the whole page in one call,
and `render` reads the result from `context.hydrated`:

```ts
const notifications = createNotifications({
  transport,
  types,
  hydrate: async (items) => ({
    tasks: await loadTaskTitles(
      items.flatMap((item) => (item.subject ? [item.subject.id] : [])),
    ),
  }),
  render: (item, { locale, hydrated }) => ({
    title: t(locale, item.type, {
      task: hydrated?.tasks.get(item.subject?.id ?? ""),
    }),
  }),
});
```

With the [`profiles` module](/docs/blocks/profiles) installed alongside,
`list({ include: ["actor"] })` adds each item's `actor` (`id`, `username`,
`fullName`, `firstName`, `lastName`, `avatar` and `avatarPath`, those the
profiles table has) in one call to `notification_actors(ids)`, which returns
only actors of the caller's own notifications.

`avatarPath` is a Storage object path. With the `avatars` option the block
turns it into `actor.avatarUrl`: pass `{ url, bucket }` for a public bucket
(`<url>/storage/v1/object/public/<bucket>/<path>`), or a function of the
path for signed or transformed URLs:

```ts
const notifications = createNotifications({
  transport,
  types,
  avatars: { url: process.env.SUPABASE_URL!, bucket: "users" },
});
```

## Realtime [#realtime]

By default each new or changed recipient row is broadcast on the user's
private topic, `notifications:{userId}`. The payload carries ids only; the
client reloads through RLS. `useNotifications` joins the topic with the
user's token, reloads on each message and after a reconnect, and merges any
other sources you pass:

```tsx title="components/inbox.tsx"
"use client";

import { useNotifications } from "better-supabase/blocks/notifications/react";

export function Inbox({
  userId,
  organizationId,
}: {
  userId: string;
  organizationId: string;
}) {
  const { items, count } = useNotifications({
    topic: `organization:${organizationId}:notifications:${userId}`,
    load: () =>
      fetch(`/api/notifications?organization=${organizationId}`).then((res) =>
        res.json(),
      ),
  });
  return <Bell count={count} items={items ?? []} />;
}
```

| Option         | Default                  | Meaning                                                                  |
| -------------- | ------------------------ | ------------------------------------------------------------------------ |
| `realtime`     | `broadcast`              | `broadcast`, `changes` (adds the table to `supabase_realtime`) or `none` |
| `topic`        | `notifications:{userId}` | The topic template; `{tenantId}` adds the organization                   |
| `createdEvent` | `notification_created`   | The broadcast event for a new notification                               |
| `updatedEvent` | `notification_updated`   | The broadcast event for a read, dismissed or resolved one                |

The module adds a `realtime.messages` policy so each user can join only
topics that match the template with their own id.

## Subscriptions and preferences [#subscriptions-and-preferences]

Users watch a subject with `subscribe({ subject, level })`. Level `all` gets
routine activity too, `participating` only what involves them, and `ignore`
mutes the subject even when a sender names them. `null` removes the
subscription.

To make the author or an assignee follow a subject without overriding a
choice they made, pass `ifAbsent: true` with their `userId`:

```ts
await notifications
  .subscribe({
    subject: { type: "task", id: taskId },
    level: "participating",
    tenant: organizationId,
    userId: assigneeId,
    ifAbsent: true,
  })
  .orThrow();
```

It adds the level only when the member has none for the subject, so their
`ignore` or `all` stays. A signed-in sender with the send permission in the
tenant may do this for another member of it; without `ifAbsent`, only the
service sets another member's level. In SQL the flag is
`set_notification_subscription(..., member, if_absent => true)`.

The caller reads their own subscriptions with `subscriptions({ tenant?,
subject? })`, where `subject` is `{ type, id? }`, and their channel choices
with `preferences({ tenant? })`. With `tenant`, `preferences` returns the
choices for that organization and the ones that hold everywhere (`tenant`
null), so a settings page can show both. In SQL they are
`list_notification_subscriptions(tenant, subject_type, subject_id)` and
`list_notification_preferences(tenant)`, granted to `authenticated`.

`setPreference({ type, channel, enabled, tenant? })` turns a type (or `*`)
on or off on a channel. The most specific row wins: the organization and
type, the organization and `*`, everywhere and type, everywhere and `*`,
then `options.channelDefaults`. Without a default, `in_app` is on and every
other channel is off until the user turns it on, so nobody gets email they
did not ask for. The block resolves the preferences of all recipients in one
query, and `notify` refuses more than `options.maxRecipients` (1,000)
recipients with `NOTIFICATION_TOO_MANY_RECIPIENTS`; fan out larger audiences
from a job.

## Email, push and other channels [#email-push-and-other-channels]

Every channel except `in_app` gets a `pending` delivery row per recipient.
Pass a `NotificationChannel` per channel and call `deliver()` from a cron
route or a job, with a service connection:

```ts title="app/api/notifications/deliver/route.ts"
import "server-only";

import {
  createNotifications,
  sqlTransport,
} from "better-supabase/blocks/notifications";

import { types } from "@/lib/notifications";

const notifications = createNotifications({
  transport: sqlTransport(postgres.admin),
  types,
  channels: [
    {
      apiVersion: 1,
      name: "email",
      send: async ({ email, text, notification }) => {
        if (!email) return { status: "skipped" };
        const { id } = await resend.emails.send({
          to: email,
          subject: text?.title ?? notification.type,
        });
        return { provider: "resend", providerMessageId: id };
      },
    },
  ],
});

export async function GET() {
  return Response.json(await notifications.deliver({ budgetMs: 50_000 }));
}
```

`deliver` claims pending rows with `for update skip locked` and leases them
(`lease`, five minutes by default), so two runs never send the same message.
A channel that throws leaves the row pending until `next_attempt_at`, a
random time up to 30 seconds that doubles per attempt to at most an hour.
The database marks it `failed` after `maxAttempts` (5), and also when a
worker died while it held the last attempt. `testNotificationChannel` from
`better-supabase/testing` checks a channel against the contract.

Run `purge()` (`purge_notifications(older_than, batch)` in SQL) from a
scheduled job to delete notifications older than 90 days with their
recipients and deliveries, 10,000 per call.

## From outbox events [#from-outbox-events]

`notifications.sink({ map })` is an outbox sink that turns events into
notifications. `map` receives each CloudEvent, with the
`dev.better-supabase.` prefix stripped from its type, and returns a
notification or `null` to skip it:

```ts title="app/api/cron/notify/route.ts"
await outbox.relay(
  "notify",
  notifications.sink({
    map: (event) =>
      event.type === "organization.member_added"
        ? {
            type: "organization.joined",
            recipients: [String(event.data.userId)],
            data: { title: "You joined the organization" },
          }
        : null,
  }),
);
```

The notification's `key` defaults to the event id and its `tenant` to the
event's `partitionkey`, so a replayed batch sends nothing twice. A failed
send throws, and the relay retries the batch.

SQL modules notify through the notifications module in the same
transaction, not through this sink. The comments module already sends a
notification for every mention (`comment.mentioned`), so don't map that
event again.

## Extending it [#extending-it]

| Hook or event                   | Use                                                                                                                                      |
| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `notification_audience` hook    | A SQL function that adds recipients, e.g. everyone with a role on the subject                                                            |
| `before_notification_send` hook | A SQL function that gets the notification as `jsonb` and raises to refuse it                                                             |
| `after_notify` hook             | A SQL function that runs in the same transaction after the rows are written                                                              |
| `hooks` option                  | Refuses, rewrites or observes any method in TypeScript, e.g. quiet hours on `send`; see [Extending blocks](/docs/extending/blocks#hooks) |
| `onSent` option                 | Runs after `send` stores a notification, e.g. to call Next's `updateTag`                                                                 |
| `notification.*` block events   | `created`, `delivered` and `failed` on `betterSupabase.events`                                                                           |
| `notifications.send` permission | Rename it with `sql.modules.notifications.permissions.send`                                                                              |

## Existing tables [#existing-tables]

The managed tables are `notification_events` (with `type`, `data` and
`actor_id`), `notification_recipients` (keyed on `user_id`),
`notification_deliveries`, `notification_subscriptions` and
`notification_preferences`, keyed on `organization_id`. Adopt yours and
rename what differs; map columns and tables you don't have to `null`:

```ts title="better-supabase.config.ts"
export default defineConfig({
  sql: {
    modules: {
      notifications: {
        mode: "adopt",
        schema: "public",
        idType: "uuid",
        tables: { subscriptions: null },
        columns: {
          events: { key: null, actor: "actor_user_id", data: "metadata" },
          recipients: { user: "recipient_user_id", resolvedAt: null },
        },
        options: {
          topic: "organization:{tenantId}:notifications:{userId}",
          channels: ["in_app", "email"],
        },
      },
    },
  },
});
```

Without a key column, keyed sends derive the event id from the organization
and key instead: a UUIDv8 (RFC 9562) built from the MD5 of both, so the id
stays deterministic and passes strict validators such as `z.uuid()`. Events
stored by an earlier version under the plain MD5 id keep that id, and a
keyed send that matches one still returns it instead of notifying twice. A recipient with in-app off for a type still gets a row,
stored as dismissed, so other channels can deliver it.

## Error codes [#error-codes]

| `hint`                             | When                                                                |
| ---------------------------------- | ------------------------------------------------------------------- |
| `NOTIFICATION_TYPE_REQUIRED`       | `notify` without a type                                             |
| `NOTIFICATION_TYPE_UNKNOWN`        | `send` with a type that isn't in `types` (checked in TypeScript)    |
| `NOTIFICATION_DATA_INVALID`        | stored `data` that its type's schema rejects on a read (TypeScript) |
| `NOTIFICATION_FORBIDDEN`           | The sender lacks `notifications.send` in the organization           |
| `NOTIFICATION_PRIORITY_UNKNOWN`    | A priority that isn't in `options.priorities`                       |
| `NOTIFICATION_ACTIVITY_UNKNOWN`    | An `activity` other than `participating` or `all`                   |
| `NOTIFICATION_LEVEL_UNKNOWN`       | A subscription level other than `participating`, `all` or `ignore`  |
| `NOTIFICATION_STATUS_UNKNOWN`      | Completing a delivery with a status the block doesn't know          |
| `NOTIFICATION_TOO_MANY_RECIPIENTS` | More recipients than `options.maxRecipients`                        |