Skip to content

Notifications API

The notifications API provides your personal in-app inbox, the channel-preference matrix, your email delivery schedule (quiet hours + digests), and absence/vacation mode. All endpoints are account-wide — they span every organization you belong to. Authentication is required; no organization context is needed.

Real-time delivery

In-app notifications are pushed over ActionCable (WebSocket). The dashboard subscribes to NotificationsChannel and updates the inbox bell without polling. When the WebSocket connection is unavailable the dashboard falls back to a 60-second polling interval on GET /notifications/unread_count.


Inbox

List notifications

GET /notifications
Authorization: Bearer <token>

Returns your notifications, newest first. The inbox is account-wide — each row carries the organization it belongs to (organization_id, organization_name).

Query parameters

Parameter Type Default Description
unread boolean true → unread only; false → read only; absent → all
limit integer 20 Max rows. Clamped to 1–100.
offset integer 0 Pagination offset ("load more").

Response 200

{
  "metadata": { "organization_id": null, "timestamp": "2026-06-30T20:00:00Z" },
  "data": [
    {
      "id": "a1b2c3d4-...",
      "category": "feature_request",
      "event_type": "approved",
      "organization_id": "org-uuid",
      "organization_name": "Acme GmbH",
      "payload": {
        "feature_key": "kubernetes",
        "project_id": "proj-uuid",
        "project_name": "production",
        "request_id": "req-uuid",
        "actor": { "id": "acct-uuid", "name": "Bernd Hansen" }
      },
      "read_at": null,
      "created_at": "2026-06-30T18:42:00Z"
    }
  ]
}

read_at: null means unread.


Unread count

GET /notifications/unread_count
Authorization: Bearer <token>

Response 200

{
  "metadata": { "organization_id": null, "timestamp": "2026-06-30T20:00:00Z" },
  "data": { "unread_count": 3 }
}

Mark one notification read

PATCH /notifications/{id}/read
Authorization: Bearer <token>

Marks a single notification read. A row outside the caller's own scope is indistinguishable from a non-existent one — returns 404.

Response 200

{
  "metadata": { "organization_id": null, "timestamp": "2026-06-30T20:00:00Z" },
  "data": { "id": "a1b2c3d4-...", "read_at": "2026-06-30T20:01:00Z" }
}

Mark all read

POST /notifications/mark_all_read
Authorization: Bearer <token>

Marks every unread notification in the caller's inbox read. Account-wide.

Response 200

{
  "metadata": { "organization_id": null, "timestamp": "2026-06-30T20:00:00Z" },
  "data": { "ok": true }
}

Notification categories

The platform ships a seeded catalog of notification categories. Use this endpoint to render the preferences matrix and to understand which categories are mandatory for organization administrators.

List categories

GET /notification_categories
Authorization: Bearer <token>

Response 200

{
  "metadata": { "organization_id": null, "timestamp": "2026-06-30T20:00:00Z" },
  "data": [
    {
      "id": "uuid",
      "key": "security",
      "label": "Security",
      "default_channels": ["in_app", "email"],
      "mandatory_for_admins": true,
      "severity": "critical",
      "feature_key": null,
      "superadmin_only": false
    },
    {
      "id": "uuid",
      "key": "billing",
      "label": "Billing",
      "default_channels": ["in_app", "email"],
      "mandatory_for_admins": true,
      "severity": "notice",
      "feature_key": null,
      "superadmin_only": false
    },
    {
      "id": "uuid",
      "key": "feature_request",
      "label": "Feature Requests",
      "default_channels": ["in_app", "email"],
      "mandatory_for_admins": false,
      "severity": "info",
      "feature_key": null,
      "superadmin_only": false
    }
  ]
}

Category fields

Field Description
key Stable identifier used in preference rows.
default_channels Channels active when no preference row exists (in_app, email).
mandatory_for_admins If true, org admins cannot disable in_app for this category — the platform forces it on regardless of their preference.
severity info, notice, or critical — used for visual emphasis in the UI.
feature_key Non-null for feature-gated categories; absent entries apply to all accounts.
superadmin_only If true, this category is emitted by the platform only and is not user-configurable.

Seeded categories

key Label Mandatory for admins Severity
security Security yes critical
billing Billing yes notice
platform_incident Platform Incidents yes critical
capability_update Capability Updates yes notice
alert_escalation Alert Escalations yes critical
feature_request Feature Requests no info
broadcast Broadcasts no info (superadmin-only)

Notification preferences

The preferences matrix stores per-channel opt-in/out decisions for each category. An absent row falls back to the category default_channels.

Mandatory override

For categories with mandatory_for_admins: true, org admins always receive in_app delivery regardless of their preference setting. The API enforces this silently — the preference row is saved but overridden at delivery time. email delivery remains opt-out-able for all categories, including mandatory ones.

Get preferences

GET /notification_preferences
Authorization: Bearer <token>

Returns all saved preference rows for the authenticated account.

Response 200

{
  "metadata": { "organization_id": null, "timestamp": "2026-06-30T20:00:00Z" },
  "data": [
    { "id": "uuid", "category_key": "security", "channel": "email", "enabled": false },
    { "id": "uuid", "category_key": "feature_request", "channel": "in_app", "enabled": true }
  ]
}

Rows that do not appear here are using the category default from GET /notification_categories.


Update preferences (upsert)

PATCH /notification_preferences
Authorization: Bearer <token>
Content-Type: application/json

Upserts one or more preference rows in a single atomic operation. Missing rows are created; existing rows are updated. Send only the rows that changed.

Request body

{
  "preferences": [
    { "category_key": "billing",  "channel": "email",  "enabled": false },
    { "category_key": "security", "channel": "in_app", "enabled": true  }
  ]
}
Field Required Values
category_key yes Any key from GET /notification_categories
channel yes in_app, email, phone, or sms (Wave 9). phone/sms only ever fire for accounts with a verified phone number — see the Telephony & SMS API (SMS channel). No category defaults to phone or sms; both are opt-in only.
enabled yes true or false

Response 200

{
  "metadata": { "organization_id": null, "timestamp": "2026-06-30T20:00:00Z" },
  "data": { "ok": true }
}

Response 422 — invalid channel value or malformed row:

{
  "code": "unprocessable_entity",
  "message": "Validation failed: Channel is not included in the list"
}

Email delivery schedule

Wave 6: per-account quiet hours and a digest mode, so a noisy category doesn't mean one email per event around the clock. This governs email only — in-app and phone delivery are unaffected by quiet hours or digest mode.

Mandatory-instant invariant

security, platform_incident, and alert_escalation always send instantly — they bypass both quiet hours and digest mode entirely, regardless of this schedule's settings. This is a subset of the five mandatory-for-admins categories that force in-app delivery — billing and capability_update are also mandatory in-app but are not exempt here; they can still be held by quiet hours or batched into a digest like any other category. mandatory_instant_categories on this resource always reflects the narrower (email-exempt) list, not the full mandatory-in-app one.

Get the current schedule

GET /notification_schedule
Authorization: Bearer <token>

Account-wide; no organization context needed. When the account has no schedule row yet, returns an unpersisted instant/no-quiet-hours default stub rather than 404 — a schedule is always conceptually present, just unconfigured.

Response 200

{
  "metadata": { "organization_id": null, "timestamp": "2026-07-03T20:00:00Z" },
  "data": {
    "email_mode": "instant",
    "quiet_start": null,
    "quiet_end": null,
    "timezone": "Europe/Berlin",
    "digest_hour": 8,
    "mandatory_instant_categories": ["security", "platform_incident", "alert_escalation"]
  }
}

Update the schedule (upsert)

PATCH /notification_schedule
Authorization: Bearer <token>
Content-Type: application/json

{
  "email_mode": "daily_digest",
  "quiet_start": "22:00",
  "quiet_end": "07:00",
  "timezone": "Europe/Berlin",
  "digest_hour": 8
}

Upserts the account's single schedule row — a second call updates the same row rather than creating a duplicate.

Field Description
email_mode instant (default), hourly_digest, or daily_digest.
quiet_start / quiet_end "HH:MM", 24h, or null (never quiet). A window that wraps midnight (e.g. 22:0007:00) is supported — evaluated tz-aware in timezone. quiet_start is inclusive, quiet_end exclusive. Rejected with 422 if malformed (not HH:MM) or out of range (hour >= 24 or minute >= 60).
timezone An IANA/Rails time zone name, e.g. "Europe/Berlin". Rejected with 422 if invalid.
digest_hour 023 — the local hour (in timezone) a daily_digest flushes at. Rejected with 422 outside that range.

How the modes behave:

  • instant — every non-held email sends right away, as today.
  • hourly_digest — non-mandatory emails accumulate and are batched into one summary email roughly once an hour (see DigestFlushJob below).
  • daily_digest — non-mandatory emails accumulate all day and flush in one summary email at digest_hour local time.
  • Quiet hours hold regardless of mode — while quiet_startquiet_end is active, non-mandatory email never sends, even under email_mode: "instant". It flushes as part of the next digest cycle (or, in instant mode, as soon as quiet hours end).

A daily_digest inside quiet hours never flushes

If digest_hour falls inside the configured quiet window, the quiet-hours gate always wins ahead of the cadence check — that daily digest is permanently unreachable. This is the operator's own configuration choice, not a bug; pick a digest_hour outside your quiet window.

Response 422validation_failed (bad email_mode, malformed or out-of-range quiet_start/quiet_end, invalid timezone, or digest_hour outside 0..23).

Recurring jobs

Two Solid Queue recurring jobs power delivery scheduling and retention — relevant if you're capacity-planning the worker deployment, not something either endpoint above requires you to interact with directly:

Job Schedule Does
DigestFlushJob Every 15 minutes For every account with pending held items, checks whether its schedule is due to flush and — if so — sends one summary email covering everything held since the last flush, then marks those items flushed. A crash between sending and marking re-sends the same batch on the next run rather than silently dropping it. One account's failure doesn't block the rest of that run's batch.
Alerts::TimelinePruneJob Daily Deletes alert timeline events and notification endpoint deliveries older than the 90-day retention window.

Absence / vacation mode

Wave 6, Amendment A1: per-account absence/vacation windows. While an absence is active:

  • Email and phone delivery skip the account for non-mandatory categories — a genuine skip, not a hold; nothing accumulates for later delivery. The three mandatory-instant categories (security, platform_incident, alert_escalation) are unaffected and still deliver.
  • In-app notifications are unaffected — they keep accumulating silently, so nothing is lost by the time the account returns.
  • On-call resolution excludes the account from whatever an on-call roster rule would otherwise resolve to, before the roster's own never-page-nobody fallback runs — see Absence exclusion for the full resolution order, including the all-absent-roster case (still pages, never pages nobody).

Multiple absences per account are expected (e.g. a half-day appointment and a week-long vacation coexisting) — there is no uniqueness constraint and no PATCH in v1: correct a window by deleting it and creating a new one.

List absences

GET /notification_absences
Authorization: Bearer <token>

Returns the current account's absences, ordered by starts_at. Account-wide; no organization context needed.

Response 200

{
  "metadata": { "organization_id": null, "timestamp": "2026-07-03T20:00:00Z" },
  "data": [
    { "id": "uuid", "starts_at": "2026-07-14T00:00:00Z", "ends_at": "2026-07-21T00:00:00Z", "note": "Summer vacation", "created_at": "2026-07-03T20:00:00Z" }
  ]
}

Create an absence

POST /notification_absences
Authorization: Bearer <token>
Content-Type: application/json

{ "starts_at": "2026-07-14T00:00:00Z", "ends_at": "2026-07-21T00:00:00Z", "note": "Summer vacation" }

Always creates a new row. An account is "absent" on the half-open window [starts_at, ends_at) — inclusive start, exclusive end.

Field Required Description
starts_at / ends_at yes ends_at must be strictly after starts_at, or 422 validation_failed.
note no Free-text, e.g. "Summer vacation".

Response 201 — the absence. Response 422validation_failed.

Delete an absence

DELETE /notification_absences/{id}
Authorization: Bearer <token>

Account-scoped — an id belonging to a different account 404s rather than leaking its existence. Response 204 — deleted.


Platform broadcasts

Banner broadcasts are platform-wide announcements published by a superadmin. They appear at the top of the dashboard UI and can be dismissed per-account.

Active broadcasts

GET /broadcasts/active
Authorization: Bearer <token>

Returns published, banner-eligible broadcasts the current account has not dismissed. Returns the 10 most recently published.

Response 200

{
  "metadata": { "organization_id": null, "timestamp": "2026-06-30T20:00:00Z" },
  "data": [
    {
      "id": "uuid",
      "title": "Maintenance tonight",
      "body": "We will perform scheduled maintenance on 2026-07-01 00:00–02:00 UTC.",
      "severity": "notice",
      "banner": true,
      "published_at": "2026-06-30T20:00:00Z"
    }
  ]
}

severity values: info, notice, critical.


Dismiss a broadcast

POST /broadcasts/{id}/dismiss
Authorization: Bearer <token>

Records a per-account dismissal. Idempotent — calling again for an already-dismissed broadcast returns 200 without error. An unknown id also returns 200.

Response 200

{
  "metadata": { "organization_id": null, "timestamp": "2026-06-30T20:00:00Z" },
  "data": { "ok": true }
}

Error responses

All error responses follow the standard API envelope:

{
  "code": "not_found",
  "message": "Notification not found",
  "source_service": "notifications",
  "source_operation": "find",
  "retryable": false
}
Status code When
401 unauthorized Not authenticated
404 not_found Notification, or an absence id, not in caller's scope
422 unprocessable_entity Invalid PATCH /notification_preferences body
422 validation_failed Invalid PATCH /notification_schedule or POST /notification_absences body