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¶
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¶
Response 200
{
"metadata": { "organization_id": null, "timestamp": "2026-06-30T20:00:00Z" },
"data": { "unread_count": 3 }
}
Mark one notification read¶
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¶
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¶
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¶
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)¶
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¶
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:00–07: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 | 0–23 — 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 (seeDigestFlushJobbelow).daily_digest— non-mandatory emails accumulate all day and flush in one summary email atdigest_hourlocal time.- Quiet hours hold regardless of mode — while
quiet_start–quiet_endis active, non-mandatory email never sends, even underemail_mode: "instant". It flushes as part of the next digest cycle (or, ininstantmode, 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 422 — validation_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¶
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 422 — validation_failed.
Delete an absence¶
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¶
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¶
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 |