Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Notifications

The dashboard's topbar bell icon (added in v0.9.0) shows an unread badge and a popover of the most recent notifications. Clicking through goes to /notifications, the full feed.

Notifications are a durable in-app record — the SSE stream (Real-time scan progress) shows scans as they happen, notifications stay around after the SSE has closed.

What gets a notification

Auto-created by the orchestrator on:

Trigger eventConditionSeverity
scan.completeonly when findings_count > 0Critical / high / medium / low / info — derived from the highest-severity finding in the scan
scan.failedalwayshigh
scanner.failedalwaysmedium

Why findings_count > 0 for scan.complete?

Successful zero-finding scans don't spam the bell. If your CI runs SecureScan on every push of a clean repo, you don't get 50 notifications a day saying "all clear." A failing scan still notifies regardless — silent failure is the bug.

The filter logic lives in backend/securescan/api/scans.py under _create_notification_for_event.

Bell icon

  ┌──────────────────────────────────────┐
  │   ●3   Notifications                 │   <- bell with unread count badge
  └──────────────────────────────────────┘
        ↓ click
  ┌──────────────────────────────────────────────────────────┐
  │ Notifications                                  Mark all  │
  ├──────────────────────────────────────────────────────────┤
  │ ● critical  3 critical findings on /home/me/proj-a        │
  │             scan 0f1a93cb · 2 minutes ago                 │
  ├──────────────────────────────────────────────────────────┤
  │ ● high      Scan failed: nmap — connection timed out      │
  │             scan 0d2c... · 5 minutes ago                  │
  ├──────────────────────────────────────────────────────────┤
  │ ● medium    Scanner skipped: zap — install /usr/share/... │
  │             scan 0d2c... · 5 minutes ago                  │
  ├──────────────────────────────────────────────────────────┤
  │                  See all notifications →                  │
  └──────────────────────────────────────────────────────────┘
  • Polled every 30s via GET /api/v1/notifications/unread-count.
  • 360px popover, 10 most recent.
  • Severity dot prefix per row.
  • Click a row → marks read, navigates to the scan detail.

Full feed (/notifications)

A page with the same rows, no truncation, plus filter chips:

  • All — every notification.
  • Unreadread_at IS NULL.
  • Readread_at IS NOT NULL.

Sorted newest-first. Shows the same severity / title / scan id / timestamp. Bulk action: "Mark all as read".

API

MethodPathScopeNotes
GET/api/v1/notificationsreadNewest first. Query: unread_only=true, limit=50 (silently capped at 200).
GET/api/v1/notifications/unread-countreadReturns {"count": N}. Index-only query, cheap to poll.
PATCH/api/v1/notifications/{id}/readwriteReturns the updated row. 404 if the id is unknown.
PATCH/api/v1/notifications/read-allwriteReturns {"marked_read": N}. Idempotent: a second call returns {"marked_read": 0}.

Source: backend/securescan/api/notifications.py.

Notification shape

{
  "id": "n-9d2f3a1b",
  "severity": "critical",
  "title": "3 critical findings on /home/me/proj-a",
  "body": "Scanners run: semgrep, bandit, trivy",
  "scan_id": "0f1a93cb-44c2-4c8e-9f92-0a7c5a2e1b51",
  "created_at": "2026-04-29T20:11:09.123456",
  "read_at": null
}

Examples

Poll for unread count

$ curl -s -H "X-API-Key: $K" \
    http://127.0.0.1:8000/api/v1/notifications/unread-count
{"count":3}

List the latest 10 unread

$ curl -s -H "X-API-Key: $K" \
    "http://127.0.0.1:8000/api/v1/notifications?unread_only=true&limit=10" \
    | jq '.[].title'
"3 critical findings on /home/me/proj-a"
"Scan failed: nmap — connection timed out"
"Scanner skipped: zap — install /usr/share/zaproxy/zap.sh"

Mark one read

$ curl -s -X PATCH \
    -H "X-API-Key: $K" \
    "http://127.0.0.1:8000/api/v1/notifications/n-9d2f3a1b/read" | jq .
{
  "id": "n-9d2f3a1b",
  "severity": "critical",
  "title": "3 critical findings on /home/me/proj-a",
  ...,
  "read_at": "2026-04-29T20:14:00.000000"
}

Mark all read

$ curl -s -X PATCH -H "X-API-Key: $K" \
    http://127.0.0.1:8000/api/v1/notifications/read-all
{"marked_read":3}

A second call returns {"marked_read": 0} — idempotent.

Retention

Read notifications older than 30 days are pruned at backend startup. Unread notifications are kept indefinitely. The pruning is defensive — a long-running deployment that never restarts would accumulate forever; the on-startup sweep is enough for the typical operator-managed lifecycle.

If you need different retention, run periodic restarts or open an issue to discuss a configurable knob.

Multi-tenant note

v0.9.0 is single-tenant: every authenticated browser session sees the same notifications. There is no per-user scoping. The schema and endpoints are shaped so a user_id query param can be added later without breaking existing callers, but the v0.9.0 contract is "one queue per deployment."

If you have an internal-tooling stack with a single SecureScan deployment serving a small team, single-tenant is the right shape. For SaaS-style multi-tenancy, look at the v1.0 roadmap.

How notifications relate to webhooks

TriggerIn-app bellWebhook
scan.complete✓ (when findings_count > 0)
scan.failed
scanner.failed
scan.start / scanner.start / scanner.complete / scanner.skipped / scan.cancelled

Both surfaces consume the same SSE event stream. Notifications are "the operator's inbox"; webhooks are "external integrations." See Webhooks.

Next