Notifications
Multi-channel notification system — per-user inbox, email, and mobile push fan-out from a single producer call.
Notifications are the unified path for telling users something happened — a new feedback item arrived, an issue digest is ready, a manual job they triggered finished. Producers (the issue-scan job, feedback ingest, manual job runs) write a single record; the dispatcher fans it out across whichever channels each user has enabled.
Three Primitives
The system is backed by three tables. Keeping them separate means a failed email retry doesn't touch the inbox row, and "did the mobile push actually send?" is answerable without grepping a status grab-bag.
| Table | Purpose |
|---|---|
notifications | Per-user inbox row. One per recipient, written synchronously on enqueue. Carries the pre-rendered title, body, optional deep link, structured data, read_at, deleted_at. |
notification_deliveries | Per-channel attempt log. One row per (notification, channel). Status moves `pending → sent |
user_devices | Push token registry. One channel (mobile_push) with a per-row platform (ios |
Channels
Notifications fan out across channels. The current set is in_app, email, and mobile_push; adding Telegram, Slack, or webhook channels means dropping in a new ChannelAdapter and extending NOTIFICATION_CHANNELS — no producer change.
| Channel | Description |
|---|---|
in_app | Inbox row in the dashboard at /dashboard/notifications, with an unread badge on the user-menu avatar (polled every 30s). Always written synchronously. |
email | HTML email via Resend (or console logger in dev). Per-type formatters (sendIssueDigest, sendJobAlert) plus a generic fallback. |
mobile_push | Push to every registered mobile device. The adapter routes per user_devices.platform: iOS rows go to APNs HTTP/2 with the unread count as badge, Android rows are reserved for the FCM transport that will land later. Token-based APNs auth with an Apple .p8 key — see APNs configuration for env vars. |
Notification Types
Each notification has a type that determines its formatters and which channels users can toggle.
| Type | Trigger | Channels | Default On |
|---|---|---|---|
issue.new | Fires from the issue_scan job at the end of every run when one or more production issues were created or regressed. One push per team per scan, summarizing all of that run's new + regressed issues — bypasses the per-project digest cadence so push lands in close to real time. Dev-build crashes are excluded. | in_app, email, mobile_push | in_app, mobile_push (email off by default) |
issue.digest | Per-project periodic digest of new and regressed issues, sent by the issue_notify job at the project's configured frequency. | in_app, email, mobile_push | email only (in_app + mobile_push off so the digest doesn't double up with the instant issue.new push) |
feedback.new | A user submitted feedback in one of the team's apps. | in_app, email, mobile_push | All three |
questionnaire.response_new | A user submitted a questionnaire response in one of the team's apps. Defaults skew to in-app only because responses can be high-volume — push and email are opt-in. | in_app, email, mobile_push | in_app only (email + mobile_push off by default) |
job.completed | A manual job run with --notify finished — only the triggering user is notified, never the whole team. | in_app, email, mobile_push | All three |
app.rating_changed | The app_store_ratings_sync job detected an increase in an app's worldwide rating count between runs — i.e. new ratings have appeared on the App Store. One notification per app per run, fanned out to every team member. First-sync (no prior baseline) is suppressed. | in_app, email, mobile_push | in_app, mobile_push (email off by default) |
app.review_new | The app_store_connect_reviews_sync job pulled at least one new written review from App Store Connect for one of the team's apps. Fired once per app per run when the app already had reviews on file (no first-sync flood). The body includes the count and a snippet of the newest review. | in_app, email, mobile_push | All three |
team.invitation | Listed for documentation but not routed through the dispatcher. Sent transactionally via EmailService directly because the recipient may not yet be a user. | — | — |
Adding a new type is three steps: append to NOTIFICATION_TYPES, add an entry to NOTIFICATION_TYPE_META with default channel state, and wire a producer call site to dispatcher.enqueue(...).
Per-User Preferences
Channel preferences live under users.preferences.notifications.types as a sparse override map: missing entries fall back to the type's defaults from NOTIFICATION_TYPE_META. Read and update via the standard user-prefs endpoints:
GET /v1/auth/mereturns the merged preferences blob.PATCH /v1/auth/meshallow-merges the top level and deep-replaces nested objects, so two browser tabs editing different sub-objects don't clobber each other.
The dashboard's profile page at /dashboard/profile/notifications renders one row per type per channel and PATCHes the override.
Per-Project Alert Frequency vs Per-User Channels
Two knobs cooperate:
- Per-project
issue_alert_frequency(none,hourly,6_hourly,daily,weekly) is the rate-limit / batching policy — how often theissue_notifyjob assembles a digest for that project. It applies to all team members. - Per-user channel toggles decide where the digest goes for each recipient — inbox only, plus email, plus mobile push, etc.
A project on daily digests with three team members where one disables email and another disables push will send three digests at the same hour, each over the channels the recipient has enabled.
System-Job Alerts Stay Direct
System job failures (db_pruning, partition_creation, attachment_cleanup, app_version_sync, etc.) are a server-owner concern, not a team-member concern. They keep going to SYSTEM_JOBS_ALERT_EMAIL via the direct email path and never enter the dispatcher, the inbox, or push. There's intentionally no system.alert notification type.
Transactional Email Stays Direct Too
Two flows bypass the dispatcher because the recipient may not yet have a users row:
- Verification codes — sent via
EmailService.sendVerificationCodestraight from the/v1/auth/send-coderoute. - Team invitations — sent via
EmailService.sendTeamInvitationfrom the team-invite flow.
Both reach the email channel directly and are never written to a notifications inbox.
APNs Setup
The iOS branch of mobile_push uses token-based auth with an ES256-signed JWT (the same EC P-256 pattern Apple uses for App Store Connect API, Apple Search Ads, and Sign in with Apple). One Apple Developer account → one set of env vars covers the whole server. Configure them at APNS_KEY_ID, APNS_TEAM_ID, APNS_KEY_P8, APNS_BUNDLE_ID, and APNS_ENV — see Self-hosting configuration.
If APNS_KEY_P8 is unset the mobile_push adapter is omitted at boot and every iOS push delivery is marked skipped, so dev / local environments work without push setup.
The adapter sends sandbox-environment pushes to api.sandbox.push.apple.com and production pushes to api.push.apple.com. APNs replies of 410 Unregistered or 400 BadDeviceToken hard-delete the offending user_devices row so the server stops retrying — the next time the device opens the app it will register a fresh token.
Background Jobs
Two jobs back the system:
notification_deliver— one job perpendingrow innotification_deliveries. The handler loads the row, runs the matching channel adapter, and updates the delivery's status. See Background Jobs.notification_cleanup— daily at 6am UTC. Soft-deletes read notifications older than 30 days, hard-deletes soft-deleted notifications older than 90 days.
Permissions
Notifications and devices are user-scoped, not team-scoped. The REST routes accept JWT cookies only — agent API keys receive 403. There is intentionally no MCP tool surface for notifications: an agent has no inbox.
Related Resources
/dashboard/notifications— inbox UI/dashboard/profile/notifications— per-channel preferences- Notifications API — REST reference
- Devices API — push token registry REST reference
owlmetry notifications— CLI reference- Issues —
issue.digestproducer + per-project alert frequency - Feedback —
feedback.newproducer - Questionnaires —
questionnaire.response_newproducer - Store Ratings & Reviews —
app.rating_changedandapp.review_newproducers - Background Jobs —
job.completedproducer when triggered with--notify
