Notifications
Deliver in-app bell notifications and operator alerts, both flag-gated and queue-backed.
Two independent subsystems: the in-app bell (@repo/notifications, gated by config.notifications) for per-user alerts, and operator alerts (@repo/admin-notifications, gated by config.adminNotifications) that push business events to your chat tools.
In-app notifications
@repo/notifications powers the bell, badge, and /notifications page, served through @repo/api. Gated by config.notifications.enabled (default true) - when off, createNotification returns null, writes no rows, and the bell is hidden.
Configuration
config.notifications is a discriminated union: { enabled: false } turns it off, or the enabled object tunes these knobs.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Master flag; false hides the bell entirely |
maxPerUser | number | 100 | Per-user row cap (trimmed only by inline cleanup) |
cleanupDays | number | 30 | Delete rows older than N days |
dropdownCharLimit | number | 100 | Max characters in the dropdown preview |
pollIntervalMs | number | 30000 | Unseen-count refetch interval |
fetchCooldownMs | number | 10000 | Minimum gap between client fetches |
broadcastConcurrency | number | 5 | concurrency.limit on the queued broadcast jobs |
broadcastConcurrency caps only the email and SMS broadcast jobs. In-app fan-out is unaffected - it writes rows sequentially in batches of 1,000.
Creating notifications
createNotification({ userId, type, title, message, link?, metadata? }) inserts one row and returns its ID, or null when the flag is off. type must be one of the 18 NotificationType kinds (packages/config/src/types/index.ts): payment_success, payment_failed, plan_changed, credits_low, credits_added, subscription_expiring, team_invite_received, team_member_joined, team_member_left, team_role_changed, new_login, password_changed, two_factor_enabled, two_factor_disabled, session_revoked, system_announcement, maintenance_scheduled, feature_update.
Prefer the seven typed helpers in triggers.ts over raw inserts - each sets the correct type, a deep link, and HTML-escapes interpolated values via escapeHtml.
| Helper | Type | Links to |
|---|---|---|
notifyPlanChanged | plan_changed | /settings/billing |
notifyCreditsAdded | credits_added | /settings/billing |
notifyMemberJoined | team_member_joined | /settings/organization |
notifyMemberLeft | team_member_left | /settings/profile |
notifyRoleChanged | team_role_changed | /settings/organization |
notifyPasswordChanged | password_changed | /settings/security |
notifySessionRevoked | session_revoked | /settings/security |
Reading the bell
Service functions are all ownership-scoped by userId:
| Function | Purpose |
|---|---|
getNotifications(userId, { limit, offset }) | List non-dismissed, newest-first, with total |
getUnseenCount(userId) | Badge count (unseen + non-dismissed) |
markAsSeen(userId, ids | "all") | Clear the badge |
dismissNotification(userId, id) | Hide a single row |
The bell lives in the dashboard; wire its data up per your framework - see Data fetching.
Broadcasts and unsubscribe
createBroadcast persists an announcement and fans out across the selected channels:
- in-app: only when
config.notifications.enabled; batched inserts of 1,000. - email: queued as
announcement/broadcast.email; renders theannouncementmarketing template withsubject= title. Recipients are users who are not banned, haveemailVerified, and havemarketingOptIn.{userName}/{userEmail}placeholders in the title and message are substituted per recipient. - SMS: queued as
announcement/broadcast.sms, only whenconfig.sms.enabled- see SMS for its recipient eligibility.
Test send - sendTestAnnouncement (POST /admin/announcements/test) delivers a [TEST]-prefixed preview to the calling admin across the selected channels only. It persists no announcement row and fires no broadcast jobs.
Marketing email carries a signed unsubscribe link from generateUnsubscribeUrl(userId, config.baseUrl). The token is HMAC-SHA-256 signed with BETTER_AUTH_SECRET, expires after 30 days, and is verified in constant time by verifyUnsubscribeToken. The link POSTs to /email/preference, which toggles marketingOptIn and emits user/marketing.disabled (cancels the re-engagement email) or user/marketing.enabled (resumes a paused onboarding sequence).
Cleanup and retention
Two limits bound the table, applied by two different paths:
- Inline cleanup: runs on ~10% of inserts; deletes rows older than
cleanupDaysand trims rows beyondmaxPerUser(newest-first). - Daily cron: deletes globally by
cleanupDaysage only; it does not enforcemaxPerUser. See Background jobs.
Admin notifications
@repo/admin-notifications pushes operator alerts (signups, purchases, refunds, deletions) to Slack, Discord, and/or Telegram. Gated by config.adminNotifications (default { enabled: true }). notifyAdmins() is a no-op unless the flag is on, at least one provider is configured, and the event isn't opted out.
Providers auto-select from env vars - nothing sends until at least one set is present:
| Provider | Required env |
|---|---|
| Slack | SLACK_WEBHOOK_URL |
| Discord | DISCORD_WEBHOOK_URL |
| Telegram | TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID |
Even with enabled: true, alerts deliver nothing until at least one provider's credentials are set in Environment variables.
There are 10 AdminNotificationEvent kinds, fired from @repo/auth and payment webhooks (see Payments): user_signup, subscription_purchase, lifetime_purchase, plan_upgrade, plan_downgrade, subscription_cancel, refund, product_purchase, credits_purchase, account_deleted.
Opt individual events out via the optional events map (only false suppresses):
// packages/config/src/index.ts
adminNotifications: {
enabled: true,
events: { refund: false } // suppress refund alerts; all others stay on
}Call sites invoke notifyAdmins({ event, data }), which emits an admin-notification/send job. The job re-checks the flag, resolves the entity via getEntityInfo from @repo/audit, then fans out to every configured provider with Promise.allSettled.
Frequently asked questions
Why isn't my admin alert showing up even though enabled is true?
No provider env vars are set. notifyAdmins is a no-op until at least one of Slack/Discord/Telegram has credentials.
Do disabled notifications still hit the database?
No. With config.notifications.enabled: false, createNotification short-circuits to null before any insert, and broadcasts skip the in-app channel.
Why did old notifications survive past maxPerUser?
The cron enforces only cleanupDays. The maxPerUser cap is trimmed exclusively by the inline path, which samples ~10% of inserts - so it converges over time rather than instantly.
Newsletter
Mirror opted-in users into a Listmonk or Resend audience with a daily reconciliation job, gated by config.newsletter.
Admin Notifications
Push real-time operator alerts for signups, purchases, refunds, and cancellations to Discord, Slack, or Telegram with the @repo/admin-notifications package.