Owlmetry

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.

TablePurpose
notificationsPer-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_deliveriesPer-channel attempt log. One row per (notification, channel). Status moves `pending → sent
user_devicesPush 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.

ChannelDescription
in_appInbox row in the dashboard at /dashboard/notifications, with an unread badge on the user-menu avatar (polled every 30s). Always written synchronously.
emailHTML email via Resend (or console logger in dev). Per-type formatters (sendIssueDigest, sendJobAlert) plus a generic fallback.
mobile_pushPush 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.

TypeTriggerChannelsDefault On
issue.newFires 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_pushin_app, mobile_push (email off by default)
issue.digestPer-project periodic digest of new and regressed issues, sent by the issue_notify job at the project's configured frequency.in_app, email, mobile_pushemail only (in_app + mobile_push off so the digest doesn't double up with the instant issue.new push)
feedback.newA user submitted feedback in one of the team's apps.in_app, email, mobile_pushAll three
questionnaire.response_newA 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_pushin_app only (email + mobile_push off by default)
job.completedA manual job run with --notify finished — only the triggering user is notified, never the whole team.in_app, email, mobile_pushAll three
app.rating_changedThe 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_pushin_app, mobile_push (email off by default)
app.review_newThe 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_pushAll three
team.invitationListed 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/me returns the merged preferences blob.
  • PATCH /v1/auth/me shallow-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 the issue_notify job 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.sendVerificationCode straight from the /v1/auth/send-code route.
  • Team invitations — sent via EmailService.sendTeamInvitation from 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 per pending row in notification_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.

Ready to get started?

Connect your agent via MCP or CLI and start tracking.