Notification contract

Publishing a notification

A publisher sends one request per event. NovaBell fans it out to the requested channels.

{
  "channel": ["email", "in_app"],
  "to": {
    "subscriberId": "b6f2c1de-4a55-4a1e-9a3c-2f0e5d7a1c44",
    "email": "user@example.com"
  },
  "scope": "user",
  "severity": "info",
  "template": "iam/account-created",
  "data": { "accountName": "my-account" },
  "actionUrl": "/admincenter/accounts/b6f2c1de-4a55-4a1e-9a3c-2f0e5d7a1c44"
}

Field

Type

Required

Meaning

channel

string[]

yes

Any of email, in_app.

to.subscriberId

string

yes

The recipient. For scope: "user" this is the human’s subject identifier; for scope: "tenant" it is the Account identifier the notification belongs to.

to.email

string

when channel includes email

Destination address.

scope

"user" | "tenant"

no — defaults to user

user addresses one person. tenant addresses everyone who reads that Account’s feed.

severity

"info" | "warning" | "error" | "critical"

no — defaults to info

Drives severity routing and filtering in the reader.

template

string

yes

A template identifier in <namespace>/<name> form — see Templates.

data

object

no

Variables interpolated into the subject, the in-app title, and the email body.

actionUrl

string

no

Where the notification leads. A console-relative path is correct here; NovaBell absolutizes it against the console base when it renders the email.

Delivery outcomes

A publish request has three distinct successful outcomes, and they are not interchangeable.

Status

Body

Meaning

201

{"status": "sent"}

Accepted and dispatched to the requested channels.

200

{"status": "dropped", "reason": "globalMute"}

The recipient has muted every notification.

200

{"status": "dropped", "reason": "category-disabled", "categoryId": "…"}

The recipient has turned this template’s category off.

200

{"status": "dropped", "reason": "quiet-hours"}

The request arrived inside the recipient’s quiet-hours window, evaluated in their own time zone.

A dropped response is a normal result of a user preference, not an error. Do not retry it. Preferences are consulted only for scope: "user": one person’s mute cannot silence a tenant-wide notification.

Failures

Status

Meaning

400

Missing channel, to.subscriberId or template; or an invalid scope or severity value.

401

Missing, invalid or expired token.

403

The caller is not an authorized publisher, or the authorization decision denied the send.

500

NovaBell could not render or dispatch the notification.

502

The authorization service could not be reached. NovaBell fails closed: it denies rather than delivers.

An invalid severity rejects the whole request rather than silently dropping the filter. A partially applied filter is worse than a rejected one, because the reader’s severity routing cannot tell the difference between “no matches” and “the filter was ignored”.

Reading the feed

A console client calls these paths with the signed-in human’s bearer token and an X-Account-Id header carrying the current context. Every route below is available under both the /api/notifications and /console/notifications prefixes; a browser client uses the /console form, which the console gateway proxies.

Method and path

Purpose

GET /console/notifications/feed

Paginated feed, newest first.

GET /console/notifications/unseen/count

Badge count of unread notifications.

PATCH /console/notifications/{id}/read

Mark one notification read.

PATCH /console/notifications/read-all

Mark every unread notification read.

PATCH /console/notifications/{id}/dismiss

Hide one notification from the default feed.

PATCH /console/notifications/dismiss-all

Hide every notification from the default feed.

GET /console/notifications/categories

Registered service categories with localized display names.

Feed query parameters:

Parameter

Default

Meaning

limit

20

1–100.

offset

0

Rows to skip.

read

all

true for read only, false for unread only.

severity

all

Comma-separated subset of info,warning,error,critical. An unrecognized value returns 400.

Access is enforced per row: a request returns the caller’s own user-scope notifications plus the tenant-scope notifications of the Account named in X-Account-Id, and nothing else. A dismiss or mark-read of a notification the caller does not own returns 404 rather than 403, so probing cannot confirm that a row exists.

Dismiss is a distinct state from read. A dismissed notification is retained but hidden from the default feed; marking one read leaves it in place.

Live updates

Notification Center receives new notifications over Server-Sent Events instead of polling. EventSource cannot set an Authorization header, so the client exchanges its bearer token for a short-lived stream cookie first:

# 1. Exchange the session token for a stream cookie (204, Set-Cookie).
curl -X POST https://console.flexgalaxy.ai/console/notifications/stream/auth \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "X-Account-Id: ${ACCOUNT_ID}" \
  --cookie-jar stream.cookies

# 2. Open the stream with that cookie.
curl -N https://console.flexgalaxy.ai/console/notifications/stream \
  --cookie stream.cookies

The cookie is HttpOnly, Secure, SameSite=Strict, scoped to the stream path only, and expires after one hour. The stream emits a comment heartbeat every 25 seconds and an event: notification frame when a notification is created or dismissed for that user. Re-run the exchange when the cookie expires.