Formerly better-inbox. Same API: change your imports.
In-app notifications for better-auth apps. One plugin, one migration, one component — notifications live in your database, addressed to your users.
npm install @byo-oss/notifications @better-auth/core
- A better-auth plugin, not a platform. No services to deploy, no dashboard SaaS. The
notificationtable lives in your own database via better-auth's adapter (drizzle, prisma, kysely — whatever you already use). - Addressed to users, not email addresses.
notify({ userId })uses your better-auth user ids. Delete a user and their notifications go with them. - Organization-aware. Notify a whole org — optionally filtered by role — via the better-auth organization plugin:
notify({ organizationId, roles: ["owner", "admin"] }). - Email when it matters. Route a notification type to email and it goes out only if the notification is still unread after a delay, or right away if the user isn't in the app. It sends through any
@byo-oss/maileradapter: Wraps and your own AWS SES account, an SES client you already have, or email-sdk providers such as Resend and Postmark. - One React component.
<InboxButton />gives you the bell, unread badge, and inbox panel, styled with shadcn CSS variables (works with any shadcn theme, zero runtime dependencies, no shadcn required).
Community plugin — not affiliated with the better-auth team.
Using a coding agent? npx skills add byo-oss/notifications installs a skill that walks it through these steps (npx skills sync picks it up from node_modules too).
Requires better-auth 1.7.7+. Install @better-auth/core at the same version as better-auth:
npm install @byo-oss/notifications @better-auth/core
1. Add the plugin to your better-auth config:
// lib/auth.ts
import { betterAuth } from "better-auth";
import { inbox } from "@byo-oss/notifications";
export const auth = betterAuth({
// ...your existing config
plugins: [inbox()],
});2. Migrate. The plugin declares the notification table; your existing better-auth workflow creates it:
npx @better-auth/cli migrate --config lib/auth.ts # or: generate, for drizzle/prisma
(Point --config at your better-auth config if the CLI doesn't auto-detect it.)
3. Add the client plugin:
// lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
import { inboxClient } from "@byo-oss/notifications/client";
export const authClient = createAuthClient({
plugins: [inboxClient()],
});4. Drop in the component (any client component, e.g. your navbar):
"use client";
import { InboxButton } from "@byo-oss/notifications/react";
import { useRouter } from "next/navigation";
import { authClient } from "@/lib/auth-client";
export function Navbar() {
const router = useRouter();
return <InboxButton client={authClient} onNavigate={(href) => router.push(href)} />;
}TanStack Start / Router: pass the whole href to router.navigate({ href }), and mount the button in the root layout so navigations don't remount it. A working app with an end-to-end suite is in examples/tanstack-start.
import { useRouter } from "@tanstack/react-router";
const router = useRouter();
<InboxButton client={authClient} onNavigate={(href) => router.navigate({ href })} />Tailwind v4 skips node_modules when it scans for classes, so point it at the package from your CSS or some of the button's and panel's classes won't be generated:
@import "tailwindcss";
@source "../node_modules/@byo-oss/notifications/dist"; /* path relative to this CSS file */InboxButton renders its own <button>, so don't wrap it in another one. To match your design system's trigger, pass buttonClassName; it replaces the default look. With shadcn:
import { buttonVariants } from "@/components/ui/button";
<InboxButton client={authClient} buttonClassName={buttonVariants({ variant: "ghost", size: "icon" })} />5. Send notifications from any server code — a server action, a webhook handler, a cron job:
await auth.api.notify({
body: {
userId: comment.authorId,
type: "comment.reply",
title: `${user.name} replied to your comment`,
href: `/posts/${post.id}#comment-${comment.id}`,
},
});notify is server-only: callable through auth.api, never exposed as an HTTP route.
Inside a better-auth transaction (runWithTransaction, as in a @better-auth/scim projection), notify writes its notification and delivery rows in that transaction, so they commit or roll back with it. No email goes out for it until the transaction commits: the next processDeliveries sweep sends it.
With the better-auth organization plugin enabled, address an org instead of a user. One notification is created per member (fan-out on write), each stamped with organizationId:
// Stripe webhook: payment failed → tell the org's admins
await auth.api.notify({
body: {
organizationId: subscription.metadata.orgId,
roles: ["owner", "admin"], // optional — omit to notify every member
type: "billing.payment_failed",
title: "Payment failed — update your card",
href: "/settings/billing",
},
});To leave out the member who caused the event, pass excludeUserIds. If roles and excludeUserIds leave nobody, notify returns { count: 0, notifications: [] }; an organization with no members at all still throws.
// A comment in an org: tell every member except the author
await auth.api.notify({
body: {
organizationId: post.orgId,
excludeUserIds: [session.user.id], // the actor gets no row
type: "comment.added",
title: `${session.user.name} commented`,
href: `/posts/${post.id}`,
},
});Pass userId together with organizationId to notify one member in the org's context. It writes a single row stamped with organizationId, so it shows up under that org's organizationId filter. notify throws if the user is not a member.
await auth.api.notify({
body: {
userId: task.assigneeId,
organizationId: task.orgId,
type: "task.assigned",
title: "Assigned to you",
href: `/tasks/${task.id}`,
},
});roles and excludeUserIds apply only to fan-out, so they are rejected when userId is set.
Fan-out is capped at 1,000 members by default (inbox({ maxFanout }) to change). Over the cap, notify throws instead of hammering your database.
The plugin cleans up after the organization plugin's own endpoints: /organization/delete deletes the org's notifications, and /organization/remove-member and /organization/leave delete that member's notifications in that org. Their notifications elsewhere stay. Deliveries go with their notifications. Pass organizationCleanup: false to keep them, e.g. for an audit trail.
With @byo-oss/core, its deleteOrganizationData and deleteMemberData fan-out (called from your organizationHooks) reach this plugin too. When both run, the second finds nothing and does nothing, so leave the plugin's own cleanup on. organizationCleanup: false also leaves notifications out of core's fan-out; turn it off only to keep the rows, since then nothing deletes them.
Deleting a user removes all of their rows: through foreign keys on SQL databases, and through a user-delete hook on adapters without them (memory, MongoDB).
If you delete organizations or members some other way (a script, an admin tool, a direct database write), call the cleanup yourself:
import { deleteMemberData, deleteOrganizationData } from "@byo-oss/notifications";
await deleteOrganizationData(auth, organizationId);
await deleteMemberData(auth, { userId, organizationId });organizationId has no foreign key, because the organization plugin is optional. These functions do what a cascade would. Call them too if your own organizationHooks (afterDeleteOrganization, afterRemoveMember) throw: that error replaces the response the plugin reads, so its cleanup skips. A notification fanned out at the moment its org is deleted can also survive; running deleteOrganizationData again is safe.
Called inside a better-auth transaction (runWithTransaction, as in a @better-auth/scim projection deprovisioning a member), they run in it: their deletes commit or roll back with the rest of it.
Route a notification type to email, and it is emailed only if nobody has read or seen it in the app first. The in-app row is always written; email is a follow-up to it.
npm install @byo-oss/mailer @wraps.dev/email
import { inbox } from "@byo-oss/notifications";
import { mailerChannel } from "@byo-oss/notifications/mailer";
import { wrapsMailer } from "@byo-oss/mailer/wraps";
inbox({
appUrl: "https://app.acme.com", // turns href "/settings/billing" into a link
channels: {
// the mailer owns the sender address, so the channel takes no `from`
email: mailerChannel({ mailer: wrapsMailer({ from: "Acme <notify@acme.com>" }) }),
},
routes: {
// in-app now; email after 15 minutes if still unread, or right away if the user isn't in the app
"billing.payment_failed": [{ channel: "email", ifUnreadAfter: { minutes: 15 } }],
// in-app and email, both right away; the email goes out even if it's read first
"security.new_login": [{ channel: "email", cancelIfRead: false }],
// at most one email an hour per user, however often it fires
"run.failed": [{ channel: "email", ifUnreadAfter: { minutes: 15 }, throttle: { hours: 1 } }],
// "*": [...] would apply to every type without its own entry; types with no rule stay in-app
},
});mailerChannel({ mailer, replyTo?, render? }) from @byo-oss/notifications/mailer sends through any Mailer from @byo-oss/mailer, which your app installs alongside this package, since it imports the adapter, plus the SDK your provider needs. wrapsMailer (@byo-oss/mailer/wraps) sends through your own AWS SES account with @wraps.dev/email. sesMailer (@byo-oss/mailer/ses) takes an SESv2Client you already have. emailSdkMailer (@byo-oss/mailer/email-sdk) takes an email-sdk client for Resend, Postmark, SendGrid and others. replyTo sets a reply-to address, and render: ({ notification, user, appUrl }) => ({ subject, html, text }) replaces the default email (title, body and a View button). A PermanentMailError from the mailer fails the delivery at once, as PermanentDeliveryError; any other error retries, even one marked permanent: true. A channel is any { deliver({ notification, user, appUrl }) } that throws on failure, so other providers are a few lines. A throw retries; throw PermanentDeliveryError (or any error with permanent: true) for a send that can never succeed, such as an invalid address, and the delivery fails at once. SES reports bounces and suppressed addresses later, as events, so those still show as sent.
wrapsEmail from @byo-oss/notifications/wraps still works, but it is deprecated and will be removed in a later breaking release. Use mailerChannel with wrapsMailer for new code. It sends through @wraps.dev/email from your own AWS SES account, and takes client: new WrapsEmail({ region, ... }) and render as above.
Read and seen both cancel it. Clicking a notification marks it read. An unread row in the open panel is marked seen (seenAt) once at least half of it has been on screen for a second while the tab is visible. Rows below the scroll, or a panel flicked open and shut, don't count. Seen doesn't touch the badge or read, but it does cancel a pending email. For alerts whose email is the record, such as security events, set cancelIfRead: false and it goes out regardless.
Throttle alerts that repeat. With throttle: { hours: 1 }, a user gets at most one email of that type per hour; later ones are skipped (with detail: "throttled") and stay in the inbox. The window counts from the last email actually sent. It holds within one process; two sweeps running at once in separate processes can each send one.
"In the app" is a heartbeat. <InboxButton> polls the unread count every 30 seconds while the tab is visible, and each poll records the user as present. An admin impersonating the user (better-auth's admin plugin) doesn't count. A user with no poll in the last 2 minutes (presenceWindow) is away, so an ifUnreadAfter email goes out right away instead of waiting. Set sendNowIfAway: false on a rule to always wait. With pollInterval: 0, or on pages without the inbox mounted, users count as away.
Run the sweep on a schedule. A plugin can't run jobs, so delayed emails and retries go out when you call processDeliveries from your own scheduler, every minute or so:
// app/api/cron/inbox/route.ts (Vercel Cron, a worker, a queue: anything that calls this)
export async function GET() {
const result = await auth.api.processDeliveries({});
return Response.json(result); // { sent, skipped, retrying, failed, hasMore }
}Emails that are due right away (an away user, a rule with no delay) are sent inside notify when better-auth has a background task handler (advanced: { backgroundTasks: { handler: waitUntil } }) and the batch is 25 or fewer. Otherwise, including large org fan-outs, and any notify inside a better-auth transaction, they go out on the next sweep. Each sweep handles batchSize deliveries (100) and returns hasMore when more are due. It can also be true with nothing due: the sweep stopped at limit stale sends with no attempts left after failing rows there.
Delivery is at-least-once. Each delivery is claimed atomically before it's sent, so overlapping sweeps don't double-send. A failed send retries with backoff (1 minute, doubling, up to an hour) and is marked failed after maxAttempts (5). A worker that dies mid-send has its delivery retried five minutes later, so a crash after SES accepted the email can send it twice.
Opting out. There are no built-in preferences yet. shouldDeliver: ({ notification, user, channel }) => boolean | Promise<boolean> lets you check your own before each send.
Every delivery is a row in notificationDelivery (see Schema), with its status and the reason it was skipped or the last error.
Pass onEvent and the plugin reports what happened as ByoEvents, so other packages (an activity feed, metrics, a bridge to another tool) can react without calling into this one:
| type | userId | organizationId | subject | data |
|---|---|---|---|---|
notifications.notification.created |
the recipient | the notification's, when set | { type: "notification", id } |
{ notificationId, notificationType } |
notifications.delivery.failed |
the recipient | the notification's, when set | { type: "notificationDelivery", id } |
{ deliveryId, notificationId, notificationType, channel, attempts, reason } |
actorId is always null: notify is server-only and doesn't know who caused it. The event that led to the notify call names the actor. reason is "permanent" (the channel threw a PermanentDeliveryError), "max_attempts" (it kept failing) or "unfinished" (a send outlived its five-minute lease with no attempts left). NOTIFICATIONS_EVENT_TYPES lists the types, and NotificationsEvent is their union.
import { createEventBus } from "@byo-oss/core";
import { inbox } from "@byo-oss/notifications";
inbox({
onEvent: createEventBus([
(event) => {
if (event.type === "notifications.delivery.failed") {
metrics.increment("notification_email_failed", { reason: String(event.data.reason) });
}
},
]),
});After commit. An event fires once the write it describes has committed. Outside a transaction, notify calls the handler before it returns, and so does processDeliveries for the failures it records. Of the calls that emit events, only notify joins a caller's transaction: inside runWithTransaction, its events wait until it commits and are dropped if it rolls back; they're handled before runWithTransaction returns. The sweep (processDeliveries, or sweepAll from @byo-oss/core) never joins one: its writes commit as they happen, and its delivery.failed events fire at once, even if your transaction then rolls back. Call it outside a transaction. Join with runWithTransaction from @better-auth/core/context. A plain adapter.transaction(trx => …) isn't joined at all: the plugin's writes commit at once, along with their events. Wrapping it with runWithAdapter(trx, …) makes the writes join, but their events fire before the commit.
Errors never reach you. A handler that throws or rejects is logged (onEvent failed for <type>) and never fails notify or the sweep.
One event per row. An org fan-out to N members emits N events, each awaited in turn, inside notify (or after the commit). maxFanout bounds it. Outside a transaction, a fan-out that fails part way keeps the rows it wrote, without events for them.
No ordering, and failures can repeat. With a background task handler, an immediate send can report delivery.failed before notify has finished emitting created. delivery.failed can arrive twice for one delivery: a send that outlives its five-minute lease is retried or failed as unfinished while the original send still finishes. That original send can also succeed after the unfinished event, leaving the row sent, so check the row before acting on a failure. It's the same cause as in Email if unread: a crash after SES accepted the email can send it twice.
No content. Events carry ids, the type, the channel, the attempt count and the reason. Never the title, body, href (it can carry tokens), your data, or a channel's error text (provider errors can contain addresses). Look the row up by id if you need more.
Adapters without transactions. Events fire at once, and a throw keeps both the rows and the events. A third-party adapter whose transaction builds a new adapter object without real rollback drops the events on a throw while its rows stay.
Don't route these events back into notify. A forwarder that turns every event into a notification would loop. notify rejects types that start with notifications. (RESERVED_NOTIFICATION_TYPE), and a routes key with that prefix throws when the plugin is created. A forwarder that renames the type isn't caught, so filter notifications.* out yourself.
Tip. In your own runWithTransaction, pass { onAfterCommitHookError }. Without it, an earlier after-commit hook that throws (a better-auth databaseHooks after hook, say) makes runWithTransaction throw and skip the hooks queued after it, including these events:
await runWithTransaction(adapter, work, {
onAfterCommitHookError: (error) => console.error(error),
});inbox(options):
| Option | Type | Default | Meaning |
|---|---|---|---|
appUrl |
string | Your app's origin. Channels join a relative href onto it |
|
channels |
Record<string, InboxChannel> |
{} |
Delivery channels beyond the in-app inbox, by name |
routes |
Record<type, DeliveryRule[]> |
{} |
Which channels each type goes to. "*" is the fallback; a type with no rule stays in-app |
shouldDeliver |
({ notification, user, channel }) => boolean | Promise<boolean> |
Return false to skip a delivery, e.g. from your own preferences |
|
presenceWindow |
number (ms) | 120000 |
How recently a user must have polled to count as in the app |
maxAttempts |
number | 5 |
Sends tried before a delivery is marked failed |
batchSize |
number | 100 |
Deliveries per processDeliveries call, an integer from 1 to 1000 |
maxFanout |
number | 1000 |
Most members one organization notify may reach |
organizationCleanup |
boolean | true |
Delete notifications when an org is deleted or a member is removed or leaves. Off also leaves them out of @byo-oss/core's deleteOrganizationData/deleteMemberData fan-out |
onEvent |
ByoEventHandler |
Receives notifications.* events after the write commits. Errors are logged, never thrown |
|
schema |
Rename tables and fields |
A DeliveryRule:
| Field | Type | Default | Meaning |
|---|---|---|---|
channel |
string | A name from channels |
|
ifUnreadAfter |
{ seconds?, minutes?, hours? } |
Send only if still unread and unseen after this long. Omit to send right away | |
sendNowIfAway |
boolean | true |
With ifUnreadAfter, send at once when the user isn't in the app |
cancelIfRead |
boolean | true |
Skip the send when the notification was read or seen first |
throttle |
{ seconds?, minutes?, hours? } |
At most one send of this type per user per window |
| Endpoint | Kind | Input | Returns |
|---|---|---|---|
auth.api.notify |
server-only | body: type, title, and userId, organizationId or both (one member's row, stamped with the org; the user must be a member). Optional body, href, data (JSON). Org fan-out also takes roles and excludeUserIds |
{ count, notifications } |
auth.api.processDeliveries |
server-only | Optional body: { limit }, overriding batchSize. Call it from a cron |
{ sent, skipped, retrying, failed, hasMore } |
auth.api.listNotifications |
own rows | query: filter?: "unread" | "all", limit? (≤100), offset?, organizationId? |
{ notifications, hasMore } |
auth.api.markRead |
own rows | body: { id } |
{ notification } |
auth.api.markSeen |
own rows | body: { ids }, up to 100. Stamps seenAt, cancelling pending emails; the panel calls it for rows that have been on screen |
{ count } |
auth.api.markAllRead |
own rows | body: { organizationId? } |
{ count } |
auth.api.unreadCount |
own rows | query: { organizationId? } |
{ count } |
"Own rows" endpoints need a session and only ever see or change the caller's own notifications.
Server-only functions (not endpoints), for your own scripts and hooks:
| Function | Description |
|---|---|
deleteOrganizationData(auth, organizationId) |
Deletes an org's notifications and their deliveries → { notifications } (the count) |
deleteMemberData(auth, { userId, organizationId }) |
Deletes one member's notifications in one org → { notifications } |
exportNotificationsData(auth, { organizationId?, cursor?, limit? }) |
One page (500 by default) of notifications, each with its deliveries and the user's userEmail, as plain JSON with ISO dates → { notifications, cursor }. Pass cursor back until it's null. Pages follow createdAt, so a row deleted mid-export (a member leaving, say) doesn't make the next page skip one. An offset cursor from an earlier release throws; start over with no cursor |
The plugin also declares byo hooks, so @byo-oss/core reaches it: sweepAll(auth) runs the same sweep as processDeliveries (one cron route for every BYO plugin can replace the processDeliveries call), and core's deleteOrganizationData and deleteMemberData call the functions above. With organizationCleanup: false only the sweep is declared.
-
<InboxButton client={authClient} />— bell + badge + panel. Props:onNavigate,renderItem,pollInterval(default 30s; unread count only, paused while the tab is hidden),refreshOnFocus(defaulttrue; full refresh when the tab becomes visible),filter,organizationId,className(the wrapper),buttonClassName(replaces the trigger's default classes). The visibility refresh is skipped when the data is under 5s old; opening the panel always refreshes. Opens on an Unread tab (fetched server-side withfilter: "unread") with All second; passingfilterlocks the list and hides the tabs. -
useInbox(client, options)— build your own UI:{ notifications, unreadCount, isLoading, hasMore, loadMore, markRead, markSeen, markAllRead, refresh, refreshIfStale }. In a custom UI, useconst seenRef = useSeenOnView(markSeen)and putref={seenRef(n.id)}on each unread row, so only rows people actually look at cancel pending emails. It uses IntersectionObserver and marks nothing where that's missing. Options:pollInterval,pageSize,filter,organizationId,refreshOnFocus.refresh()always fetches (concurrent calls share one request);refreshIfStale()skips if the data is under 5s old. -
filter: "unread"fetches only unread rows, so a page is a full page —useInbox(authClient, { filter: "unread", pageSize: 5 })yields five unread notifications instead of five rows you then filter down to however many are unread. Rows you mark read stay in the list until the nextrefresh(), andloadMore()accounts for them when paging. -
<InboxPanel inbox={useInbox(...)} />— the panel without the bell. Passview+onViewChangeto control its tabs; uncontrolled it defaults to Unread. -
labelsonInboxButtonandInboxPanelreplaces any of the English strings:notifications(the bell's accessible name),unread,all,markAllRead,empty,loadMore,unreadCount(count)(announced to screen readers when the count changes) andrelativeTime(date). Keys you leave out keepdefaultInboxLabels.<InboxButton client={authClient} labels={{ unread: "Ungelesen", all: "Alle", markAllRead: "Alle gelesen" }} />
Writing your own channel (push, SMS, Slack)? Run the InboxChannel contract against it with vitest (ESM only, since vitest can't be require()d):
import { testChannel } from "@byo-oss/notifications/testing";
testChannel(() => slackChannel({ fetch: fakeFetch }), {
failing: () => slackChannel({ fetch: failingFetch }), // checks that a failed send throws
});It checks that deliver resolves for full and minimal notifications and for relative and absolute links, doesn't change its input, and throws when the send fails, which is what makes a delivery retry. If your channel reports sends that can never succeed, pass permanentlyFailing too, and it checks that those throw a PermanentDeliveryError. deliveryContext(overrides) builds a sample context for your own tests. The built-in Wraps channel runs the same suite.
These tables are a public contract: a change that breaks reading existing rows is a major release. Rename tables or fields with the schema option. Nothing is added to better-auth's own tables.
notification: one row per person per notification.
| Field | Type | Meaning |
|---|---|---|
id |
string | Primary key |
userId |
string → user.id, cascade |
Whose inbox it's in |
organizationId |
string, optional | The org it was sent in. No foreign key, because the organization plugin is optional |
type |
string | Your event name, e.g. billing.payment_failed. Picks the delivery routes |
title |
string | The headline in the inbox and the email subject |
body |
string, optional | A line of detail under the title |
href |
string, optional | Where clicking it goes. Emails join a relative path onto appUrl |
data |
json, optional | Anything else you passed to notify |
read |
boolean | Clicked or marked read |
seenAt |
date, optional | When it was first on screen. Cancels pending "if unread" emails |
createdAt |
date | When notify wrote it |
notificationDelivery: one row per notification per routed channel.
| Field | Type | Meaning |
|---|---|---|
id |
string | Primary key |
notificationId |
string → notification.id, cascade |
The notification it delivers |
userId |
string → user.id, cascade |
The recipient, copied from the notification |
type |
string | The notification's type, for throttling |
channel |
string | The channel's name in channels |
status |
string | pending, sending, sent, skipped or failed |
scheduledFor |
date | When it's due |
attempts |
number | Sends tried |
claimedAt |
date, optional | When a sweep claimed it. A claim older than five minutes is retried |
sentAt |
date, optional | When the channel accepted it |
detail |
string, optional | Why it was skipped, or the last error |
createdAt |
date | When it was scheduled |
inboxPresence: when each user last polled the inbox, so "email if unread" knows who's in the app.
| Field | Type | Meaning |
|---|---|---|
id |
string | Primary key |
userId |
string → user.id, cascade, unique |
Whose heartbeat it is |
lastSeenAt |
date | Written at most once a minute |
Add an index for the list query once you have real traffic — plugin schemas can't declare indexes, so it's one manual line, e.g. drizzle:
index("notification_user_created_idx").on(table.userId, table.createdAt)With email routes, the sweep queries notificationDelivery by status and due time:
index("notification_delivery_due_idx").on(table.status, table.scheduledFor)
// only if you use throttle:
index("notification_delivery_throttle_idx").on(table.userId, table.type, table.channel, table.sentAt)Organization cleanup looks notifications up by org, and export pages through an org's notifications by time. Both look deliveries up by notification. Postgres doesn't index foreign keys on its own:
index("notification_org_created_idx").on(table.organizationId, table.createdAt)
index("notification_delivery_notification_idx").on(table.notificationId)
// only if you export without an organizationId:
index("notification_created_idx").on(table.createdAt)No schema change and no new inbox() options.
- New:
mailerChannel.mailerChannel({ mailer, replyTo?, render? }), from@byo-oss/notifications/mailer, sends email through anyMailerfrom@byo-oss/mailer. The mailer owns the sender, so the channel has nofrom. Install@byo-oss/mailer(0.1.1 or later) in your app, since your code imports its adapter, along with the SDK your mailer needs, such as@wraps.dev/emailforwrapsMailer. wrapsEmailis deprecated. It still works and is unchanged, but it will be removed in a later breaking release. Move tomailerChannelwithwrapsMailer:
// before
channels: { email: wrapsEmail({ from: "Acme <notify@acme.com>", client: new WrapsEmail({ region }), render }) },
// after
channels: { email: mailerChannel({ mailer: wrapsMailer({ from: "Acme <notify@acme.com>", client: new WrapsEmail({ region }) }), render }) },- Check the differences.
wrapsMailerdiffers fromwrapsEmailin four ways. It fails permanently on every@wraps.dev/emailValidationError(a malformedfrom,replyToor recipient, or an empty subject) and on SESMessageRejectedandInvalidParameterValue, wherewrapsEmailfails only a bad recipient at once and retries the rest, so fixing the config no longer rescues deliveries already queued. It throws aTypeErrorwhen it is created iffromis empty, wherewrapsEmailfailed at the first send. Itsclientmust resolve{ messageId }, so a fake or wrapper that returns anything else, such as aWrapsEmailSender, needs changing. And it sends a non-ASCII display name infromRFC 2047 encoded, which needs@byo-oss/mailer0.1.1 or later (0.1.0 sends it as given).
No migration. Breaking:
- better-auth 1.7.7+. The
better-authpeer is now^1.7.7, and@better-auth/coreis a new required peer: install it at the same version asbetter-auth. Events use its after-commit hooks. - Reserved types.
notifyrejects types that start withnotifications.(RESERVED_NOTIFICATION_TYPE), and arouteskey with that prefix throws when the plugin is created. They're the plugin's own events. batchSizeis checked.inbox()throws unlessbatchSizeis an integer from 1 to 1000, the rangeprocessDeliveriesalready accepts forlimit.
New:
onEvent, withnotifications.notification.createdandnotifications.delivery.failed, delivered after commit.byohooks for@byo-oss/core(sweepAll,deleteOrganizationData,deleteMemberData). The cleanups are left out underorganizationCleanup: false.
Behaviour changes:
- Stale sends with no attempts left. A sweep now fails them one row at a time, at most
limitrows per sweep, instead of all at once. When it stops at that cap after failing rows there,processDeliveriesreturnshasMore: true, sohasMorecan be true with nothing due to send. Exhausted rows past the cap staysendinguntil a later sweep fails them; they're never retried pastmaxAttempts. Rows the sweep can't update (row-level security, say) still count againstlimit, so oncelimitof them (up to 180) come first in scan order, later exhausted rows wait.
- Every reference now declares
onDelete: "cascade":notification.userId,notificationDelivery.notificationId,notificationDelivery.userIdandinboxPresence.userId. better-auth's ownmigratealready cascaded. If you generate migrations with drizzle or prisma, regenerate and apply them so your foreign keys match. - The plugin now deletes notifications when an organization is deleted or a member is removed or leaves (see above). If you wired your own cleanup for this, you can remove it.
- The panel's tabs render "Unread" and "All" as text, instead of
unreadandallcapitalized by CSS. Tests that match the tab text exactly need the capitalized form. - Deleting a user now also removes their rows on adapters without foreign keys (memory, MongoDB).
- New, additive:
exportNotificationsData,PermanentDeliveryError,labels(includingbadge),organizationCleanup, and@byo-oss/notifications/testing.
0.6 adds a seenAt column to notification and two tables, notificationDelivery and inboxPresence. Re-run migrate (or generate) after upgrading, even if you don't use email.
If you hand useInbox your own client object rather than authClient, add markSeen: ({ ids }) => authClient.inbox.markSeen({ ids }) to it. InboxFetchClient now requires it, because without it nothing is marked seen and every unread email goes out.
In-app first: email follows a notification that went unread, and it uses the inbox's own read state to decide. Notification preferences, push, SMS, digests and realtime websockets aren't here yet. Polling + refetch on tab visibility covers the badge honestly. If you need full multi-channel delivery pipelines today, look at Novu or betternotify.
You've outgrown it when you need push, SMS or chat next to in-app, a preference center and digests across channels, non-engineers editing notification templates, or realtime delivery to thousands of concurrent users. The usual next stops are Knock, Novu and Courier.
Neither Knock nor Novu documents a way to import past notifications: their messages come from triggering a workflow, stamped with the current time and unread. So moving is a forward cutover, and your notification table stays the archive.
| Here | Knock | Novu |
|---|---|---|
user.id, user.email |
user id, email (bulk identify, 1,000 per call) |
subscriber subscriberId, email (bulk create, 500 per call) |
organizationId |
tenant id, passed on each trigger |
tenant context { type: "tenant", id } |
type |
workflow key | workflowId, conventionally lowercase with hyphens, so billing.payment_failed may need renaming |
title, body, href, data |
trigger data, rendered by the workflow's in-app template |
trigger payload |
read, seenAt |
message statuses, for messages it created | read and seen, for messages it created |
createdAt |
not settable | not settable |
notificationDelivery, inboxPresence |
no equivalent; stays here | no equivalent; stays here |
- Run both. Keep calling
auth.api.notify, and also trigger the new tool's workflow for onetype, behind a flag. - Backfill people. Bulk-import users from better-auth's
usertable, and organizations as tenants fromorganization. - Cut over one
typeat a time. Check it in the tool's activity log, then stop callingnotifyfor that type and point the bell at the tool's feed component. - Keep history readable. Leave the old inbox reachable (an "Earlier" tab, or an export into your own archive) for as long as people need it.
- Uninstall once nothing calls
notifyand nothing reads the old inbox.
Knock is the more direct fit: its tenants take organizationId as-is, and it documents setting message status through its API. Novu's in-house migration guide covers moving triggers one workflow at a time.
- Remove
inbox()frombetterAuth({ plugins }),inboxClient()from your auth client, andInboxButton/useInboxfrom your UI. - Remove the Tailwind
@sourceline and the cron that callsprocessDeliveries. - Drop the tables, the one with foreign keys to
notificationfirst:notificationDelivery, theninboxPresenceandnotification. Use the names your adapter or theschemaoption gave them.
The plugin adds no columns to better-auth's own tables (user, organization, member), so there's nothing else to clean up.
MIT
