Skip to content

Notifications (console API + realtime relay pattern)

Citadel’s notifications surface has two distinct parts today:

  1. A console-operator API (/console/v1/notifications*) that models a targeted-or-broadcast message store: send to one account by id, broadcast to everyone, list (with visibility filtering), and delete.
  2. A realtime delivery pattern built from primitives that already ship: your Lua script relays a custom envelope kind with citadel.on_message + citadel.broadcast/citadel.send, and each client SDK sends/receives that kind. There is no automatic push when POST /console/v1/notifications is called — see Realtime delivery today.

Source: src/repository/notifications.rs (the persisted notification store and its pure visibility/paging/eviction helpers), src/services/notifications.rs (the thin validate-then-delegate service), src/http/console_api/notifications.rs (HTTP handlers), src/runtime/lua.rs (the citadel.on_message/citadel.broadcast/citadel.send host API described on the Lua runtime API page).

pub enum Recipient {
User(String), // targeted at one account by user id
Broadcast, // delivered to every account (fan-out)
}

Over HTTP this is expressed as an optional user_id field: present targets one account; absent sends/represents a broadcast. A notification’s visibility follows the recipient:

  • A targeted notification (Recipient::User(id)) is visible only to that id (and to the operator-wide unfiltered view).
  • A broadcast (Recipient::Broadcast) is visible to every user filter, in addition to the unfiltered view.

The store is a single global bounded ring (default 10,000 entries, DEFAULT_NOTIFICATION_CAPACITY); the oldest entry is evicted once full. It is persisted behind the repository seam, so on the Postgres and SQLite backends the notifications survive a node restart (the in-memory backend stays non-durable by design). Notification ids are a monotonic global sequence; they stay a stable before-cursor key while retained.

Every route below requires a console bearer token from POST /console/v1/login (see Login and roles):

Authorization: Bearer <token>
Role Access
admin Read and mutate (send, delete).
viewer Read-only. A mutation attempt returns 403 forbidden.

Newest-first page of notifications.

Auth: bearer token, any role.

Query parameters

Name Type Required Meaning
user_id string no Restrict to that user’s own targeted notifications plus every broadcast. Absent lists everything (the operator-wide view).
limit integer no Page size, newest-first. Default 50, capped at 200.
before integer no Resume cursor: only notifications strictly older than this id.

Response 200 OK

{
"items": [
{ "id": 43, "user_id": "u-1", "subject": "welcome",
"content": { "level": 1 }, "code": 0,
"created_at_unix_ms": 1751791000000, "read": false }
],
"total": 1
}
Field Type Meaning
id integer Server-assigned id, monotonic, never reused.
user_id string | null Targeted recipient’s user id, or null for a broadcast.
subject string Subject line.
content object Arbitrary JSON payload (always a JSON object).
code integer Application-defined status/kind code.
created_at_unix_ms integer When it was sent (Unix milliseconds).
read boolean Whether the recipient has marked it read.
total integer Total notifications visible to the requested user_id filter, ignoring before/limit.

Errors

Status Code Cause
401 authentication_failed Missing/invalid/expired bearer token.

Example

Terminal window
curl -s "http://127.0.0.1:7350/console/v1/notifications?user_id=u-1&limit=50" \
-H "Authorization: Bearer $TOKEN"

Send a notification, targeted or broadcast.

Auth: bearer token, admin only. A viewer gets 403 forbidden.

Request body

{ "user_id": "u-1", "subject": "welcome", "content": { "level": 1 }, "code": 0 }
Field Type Required Meaning
user_id string no Target account id. Omit to broadcast to every account.
subject string yes Must be non-empty (rejected otherwise).
content object no Arbitrary JSON object payload. Defaults to {}; rejected if not a JSON object.
code integer no Application-defined status/kind code. Defaults to 0.

Response 201 Created

{ "id": 43, "user_id": "u-1", "subject": "welcome",
"content": { "level": 1 }, "code": 0,
"created_at_unix_ms": 1751791000000, "read": false }

Errors

Status Code Cause
400 invalid_request Blank subject, non-object content, or malformed body.
401 authentication_failed Missing/invalid/expired bearer token.
403 forbidden Caller is a viewer.

Audited as notifications.send (target logged as user {id} or broadcast).

Example

Terminal window
curl -s -X POST http://127.0.0.1:7350/console/v1/notifications \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"user_id":"u-1","subject":"welcome","content":{"level":1}}'

Omit user_id to broadcast:

Terminal window
curl -s -X POST http://127.0.0.1:7350/console/v1/notifications \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"subject":"server restart in 10 minutes"}'

Delete one notification.

Auth: bearer token, admin only. A viewer gets 403 forbidden.

Path parameters

Name Type Required Meaning
id integer yes The notification id (from a list page).

Response: 204 No Content (no body).

Errors

Status Code Cause
401 authentication_failed Missing/invalid/expired bearer token.
403 forbidden Caller is a viewer.
404 not_found Unknown id.

Audited as notifications.delete.

Example

Terminal window
curl -s -X DELETE http://127.0.0.1:7350/console/v1/notifications/43 \
-H "Authorization: Bearer $TOKEN"

Every error uses the console API’s shared JSON error body:

{ "code": "not_found", "message": "notification not found" }

See the console API’s error table for the full status/code list.

There is no automatic push when a notification is sent — POST /console/v1/notifications only stores it; a recipient discovers it by polling GET /console/v1/notifications. As with Chat, the only way to reach a connected game client in real time today is the same general-purpose relay pattern: your Lua runtime script registers a handler for an application-chosen envelope kind with citadel.on_message, and forwards it to peers with citadel.broadcast (everyone) or citadel.send (one session, by its transport-level session id). Citadel reserves and holds the low kind range through 99 (see the envelope reference), so an application-defined notification kind must be 100 or higher.

Server: relay a notification-style broadcast (Lua)

Section titled “Server: relay a notification-style broadcast (Lua)”
local KIND_NOTIFY_BROADCAST = 102 -- server -> client: an app-defined notification push (app-defined kind)
-- Example: your own RPC or hook calls this to push a message to every
-- connected client right now (independent of the console notification store).
citadel.on_rpc("push_notification", function(ctx, body)
citadel.broadcast(KIND_NOTIFY_BROADCAST, body, false) -- reliable
return "ok"
end)

citadel.broadcast(kind, body, unreliable) reaches every connected session except the caller; citadel.send(session, kind, body, unreliable) targets one session by its transport-level id if you are tracking which session belongs to which user_id in your own script state.

Client: receive the custom notification kind

Section titled “Client: receive the custom notification kind”
constexpr uint16 KIND_NOTIFY_BROADCAST = 102;
auto* Citadel = GetGameInstance->GetSubsystem<UCitadelClientSubsystem>;
// Poll each tick and dispatch by kind (the single reader of the envelope queue).
uint16 Kind;
TArray<uint8> Body;
while (Citadel->Poll(Kind, Body) == ECitadelStatus::Ok)
{
if (Kind == KIND_NOTIFY_BROADCAST)
{
FString Text = FString(UTF8_TO_TCHAR(Body.GetData));
// ... show Text in a toast/notification UI ...
}
}

See the envelope format reference for the exact byte layout of framed vs. datagram encodings, and the Lua runtime API for the full citadel.on_message / citadel.broadcast / citadel.send host surface.

The visibility/paging/eviction/read-state logic lives in pure helpers unit-tested in src/repository/notifications.rs, and the persistence contract (enqueue, newest-first paging with a before cursor, ring eviction, delete, mark-read) is verified against all three backends (in-memory, SQLite always, Postgres opt-in) by tests/notifications_repository_contract.rs. The service validation (blank subject, non-object content) is unit-tested in src/services/notifications.rs. The HTTP handlers (list/send/delete, role gating, audit trail) are unit-tested in src/http/console_api/notifications.rs and covered end-to-end by tests/console_notifications.rs.

  • No realtime push. A recipient only sees a notification by polling GET /console/v1/notifications — there is no delivery over the realtime socket when they are online.
  • No detail-by-id route. See the note above; only list/send/delete are wired.
  • No notification-specific client SDK surface. The relay pattern above uses only generic envelope send/receive; there is no OnNotification-style method bound to the console notification store on any client SDK today.
  • Blueprint cannot receive raw envelope kinds today. See the Blueprint tab above.
  • Godot transport needs the native GDExtension or the Web client. poll moves bytes through the prebuilt CitadelClientNative (Windows release package); with only the GDScript files and no native binary it returns an error status. Browser exports use CitadelWebClient (no GDExtension).