Realtime API Reference
Complete API contracts for Realtime, including routes, auth, DTOs, responses, errors, examples, and integration notes.
Realtime - API Reference
Audience: Frontend engineers, mobile engineers, backend engineers, QA, and API consumers. Scope: The admin-facing live event stream and its catalogue. For the personal notification stream and push devices, see /docs/developer/notification/api.
1. Documentation Evidence
| Area | Files Inspected | What Was Verified |
|---|---|---|
| Controller | apps/api/src/modules/realtime/admin/realtime-admin.controller.ts | Routes, methods, guards, decorators, status codes. |
| DTO | apps/api/src/modules/realtime/admin/dto/realtime-catalogue-response.dto.ts | Response shape and field descriptions. |
| Contract | apps/api/src/modules/realtime/shared/realtime.contract.ts | Event/permission/sound-class mapping, wire shape of RealtimeEvent. |
| Stream service | apps/api/src/modules/realtime/shared/realtime-stream.service.ts | Frame types, heartbeat, backpressure, reconnect delay, replay semantics, closing a user's streams, the session re-read. |
| Control channel | apps/api/src/modules/realtime/shared/realtime-control.ts | realtime:control, the close-user message and its publisher. |
| Publisher | apps/api/src/modules/realtime/shared/realtime-publisher.service.ts | History buffer size, cold-start size, sequence key. |
| Error registry | apps/api/src/common/types/error-codes.ts | PERMISSION_USER_CONTEXT_MISSING is the only module-specific code. |
2. Module Summary
| Field | Value |
|---|---|
| Module name | Realtime |
| Module slug | realtime |
| Primary actors | admin (any authenticated staff session) |
| API surfaces | admin |
| Base route prefix | /api/admin/realtime |
| Auth model | JwtAuthGuard + RoleGuard; the catalogue route additionally requires the System_READ permission; the stream route requires only an authenticated session with an active role — see §5. |
| Persistence | PostgreSQL (notification table), Redis (pub/sub channel, sequence key, history list) |
| Runtime source of truth | The outbox, relayed into this module after commit. The stream is an enhancement; every admin screen remains correct on a plain refresh with the stream dead. |
| Sibling docs | Backend, Features and flows |
3. Concepts and Terminology
| Term | Meaning | Source File | Used By |
|---|---|---|---|
kind | The stable, admin-facing name of an event, e.g. feedback.submitted. Deliberately not the outbox's own eventType. | realtime.contract.ts | Catalogue response, event frames. |
soundClass | Which of four tones the admin panel plays: positive, urgent, soft, neutral. | realtime.contract.ts | Catalogue response, event frames. |
permission | The PermissionCode a subscriber must hold to receive the event — the READ code of the screen the event belongs to. | realtime.contract.ts | Catalogue response, event frames, canReceive filtering. |
Last-Event-ID | The SSE reconnect header. Sent back as the last id: the client saw, to receive only what was missed. | realtime-stream.service.ts | Stream request. |
retry: | The SSE directive telling EventSource how long to wait before reconnecting on its own. Sent once, at connect, as 3000 ms. | realtime-stream.service.ts (REALTIME_RECONNECT_DELAY_MS) | Stream response. |
| Cold start vs reconnect | A connection with no Last-Event-ID is a cold start (recent history, marked read, frame type history); one with Last-Event-ID is a reconnect (everything missed, frame type missed). | realtime-stream.service.ts | Stream response. |
4. API Surface Map
| Surface | Method | Path | Actor | Auth/Guard | Permission | Controller | Purpose |
|---|---|---|---|---|---|---|---|
| Admin | GET | /api/admin/realtime/catalogue | Admin | JwtAuthGuard, RoleGuard, IpThrottlerGuard | System_READ | RealtimeAdminController.catalogue | Every event kind the stream can emit, its sound class, and its required permission. |
| Admin | GET | /api/admin/realtime/stream | Admin | JwtAuthGuard, RoleGuard | None declared — allowlisted by design; per-event filtering happens inside the stream | RealtimeAdminController.stream | Long-lived text/event-stream of admin notifications, filtered per subscriber. |
Both routes are declared with no NestJS version segment; the runtime path is /api/admin/realtime/..., matching every other route in this codebase.
5. Auth, Identity, and Permissions
| Surface | Guard/Decorator | Identity Shape | Permission | Guest Allowed | Notes |
|---|---|---|---|---|---|
| Catalogue | JwtAuthGuard, RoleGuard, IpThrottlerGuard (RATE_LIMITS.ADMIN_READ) | req.user.activeRole | System_READ | No | An ordinary permissioned read route. |
| Stream | JwtAuthGuard, RoleGuard | req.user.activeRole (must be present) | None declared | No | RoleGuard allows a handler with no @Permissions() only when it is on NO_PERMISSION_ADMIN_HANDLERS, and RealtimeAdminController.stream is on it (role.guard.ts), because it has no permission to declare at the route level — the guard lets it through without reading the active role, the handler itself refuses a request with no active role, and the route is otherwise reachable to any authenticated admin; every individual event is then filtered inside RealtimeStreamService.canReceive against that admin's own resolved permissions. Superadmin bypasses the per-event filter, exactly as RoleGuard bypasses permission checks for superadmin elsewhere. |
Why the browser cannot open this route directly. EventSource cannot set an Authorization header. The intended caller is the admin panel's own server (a Next.js route handler acting as a BFF), which holds the session cookie, opens this route server-to-server with an ordinary Authorization: Bearer, and streams the bytes to the browser unmodified. See the client guide for the exact proxy behavior.
Permission resolution timing. The stream route resolves the caller's full permission set once, at connect (RoleService.getPermissionsForRoleId), and holds it for the life of the connection. A permission grant or revocation takes effect on the client's next reconnect, not mid-stream. Two things force that reconnect: removing a role from a staff member ends their open streams through the close-user control message, and a stream whose session's active role changed between authentication and registration is ended as soon as it registers. A change to a role's own permissions on the Roles screen does neither.
6. DTO and Model Reference
6.1 RealtimeCatalogueResponseDto
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
soundClasses | RealtimeSoundClass[] | Yes | N/A | Enum values only | ["positive","urgent","soft","neutral"] | realtime-catalogue-response.dto.ts |
kinds | RealtimeEventKindDto[] | Yes | N/A | N/A | See below | realtime-catalogue-response.dto.ts |
6.2 RealtimeEventKindDto (nested in kinds[])
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
kind | string | Yes | N/A | N/A | "order.confirmed" style names in the doc comment; only feedback.submitted, exam.published, exam.result_published, exam.marks_submitted exist today | realtime-catalogue-response.dto.ts |
soundClass | RealtimeSoundClass | Yes | N/A | Enum | "positive" | realtime-catalogue-response.dto.ts |
permission | string | Yes | N/A | N/A | "Feedback_READ" | realtime-catalogue-response.dto.ts |
6.3 RealtimeEvent (the SSE frame payload, not a DTO class)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
id | number | Yes | N/A | Monotonic per deployment, from Redis INCR | 4821 | realtime.contract.ts |
kind | string | Yes | N/A | One of the four currently mapped kinds | "exam.result_published" | realtime.contract.ts |
soundClass | RealtimeSoundClass | Yes | N/A | Enum | "positive" | realtime.contract.ts |
permission | string (PermissionCode) | Yes | N/A | Must exist in the permission catalogue at compile time | "ExamResults_READ" | realtime.contract.ts |
aggregateId | string | Yes | N/A | Public id of the changed thing | "0192f900-..." | realtime.contract.ts |
occurredAt | string (ISO 8601) | Yes | N/A | N/A | "2026-09-24T06:10:44.019Z" | realtime.contract.ts |
summary | string | Yes | N/A | Pre-built server-side; never raw payload | "New contact message (support)" | realtime-publisher.service.ts buildSummary |
8.1 GET /api/admin/realtime/catalogue
Purpose
Every event kind the stream can emit, its sound class, and the permission that gates it — so the admin panel's notification-preferences screen renders from this list instead of a second, hard-coded copy that would drift the moment an event is added or removed.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/realtime/admin/realtime-admin.controller.ts |
| DTO | apps/api/src/modules/realtime/admin/dto/realtime-catalogue-response.dto.ts |
| Contract | apps/api/src/modules/realtime/shared/realtime.contract.ts |
Auth and Permissions
JWT, JwtAuthGuard + RoleGuard, permission System_READ. Rate-limited by IpThrottlerGuard at RATE_LIMITS.ADMIN_READ.
Request
Headers only — Authorization. No params, query, or body.
Response
200.
{
"message": "Realtime catalogue fetched successfully",
"data": {
"soundClasses": ["positive", "urgent", "soft", "neutral"],
"kinds": [
{ "kind": "feedback.submitted", "soundClass": "soft", "permission": "Feedback_READ" },
{ "kind": "exam.published", "soundClass": "neutral", "permission": "Exams_READ" },
{ "kind": "exam.result_published", "soundClass": "positive", "permission": "ExamResults_READ" },
{ "kind": "exam.marks_submitted", "soundClass": "soft", "permission": "ExamMarksVerify_READ" }
]
},
"errorCode": null
}Side Effects
None. A pure read of an in-process constant.
Error Cases
| HTTP Status | Condition | Meaning |
|---|---|---|
401 / 403 | Missing/invalid auth, or missing System_READ | Guards |
429 | RATE_LIMITS.ADMIN_READ exceeded | IP throttled |
Edge Cases
- The list changes only when a code change adds or removes an entry in
REALTIME_EVENT_MAP— there is no admin-editable configuration behind it. - A permission listed here is guaranteed to exist in the permission catalogue at compile time, because
RealtimeEventDefinition.permissionis typed asPermissionCode.
Example Requests
curl -s "$API_URL/api/admin/realtime/catalogue" -H "Authorization: Bearer TOKEN"8.2 GET /api/admin/realtime/stream
Purpose
Long-lived text/event-stream of admin notifications. One connection serves every event kind the caller is permitted to see; the server filters per subscriber rather than exposing a stream per permission.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/realtime/admin/realtime-admin.controller.ts |
| Stream service | apps/api/src/modules/realtime/shared/realtime-stream.service.ts |
| Publisher (history/sequence sizing) | apps/api/src/modules/realtime/shared/realtime-publisher.service.ts |
Auth and Permissions
JWT, JwtAuthGuard + RoleGuard. No @Permissions() — reachable by any authenticated admin with an active role. Every individual event is then filtered against that admin's resolved permissions (or bypassed entirely for superadmin). Intended to be opened server-to-server by the admin panel's own BFF; see the client guide.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <token>. |
| Headers | No | Last-Event-ID: <number> — send the last frame id seen, on reconnect only. |
| Params / Query / Body | No | — |
Response
200, Content-Type: text/event-stream; charset=utf-8, held open indefinitely. Response headers also include Cache-Control: no-cache, no-transform, Connection: keep-alive, and X-Accel-Buffering: no (defeats nginx's default proxy buffering, which would otherwise hold every event until its buffer fills).
The stream opens with retry: 3000 (how long EventSource waits before its own automatic reconnect), then frames of these types:
| Event | id: present | Data | When |
|---|---|---|---|
ready | No | { "at": "<ISO timestamp>" } | Once, immediately after headers, before replay or any live event. |
history | Yes | A RealtimeEvent (§6.3) | Once per event in the cold-start replay (no Last-Event-ID was sent). Up to 200 events, filtered by permission, oldest first. Treat as already read — no toast, no sound. |
missed | Yes | A RealtimeEvent | Once per event newer than the sent Last-Event-ID, filtered by permission, oldest first, bounded by the 500-event history buffer. Treat as unread — this is exactly what the client did not see while disconnected. |
event | Yes | A RealtimeEvent | Once per live publish this subscriber is permitted to see, for as long as the connection is open. |
A : comment line (: keep-alive <ISO timestamp>) is written every 20 seconds. It is not a frame — EventSource never surfaces comment lines to application code — and exists only to keep idle-connection-reaping proxies from closing the stream.
Side Effects
None durable from the request itself. Registers an in-memory listener and closer on this API instance for the life of the connection — the closer also indexed by the caller's user id, so a close-user control message can find it — and removes them on disconnect. Right after registering, reads the session's active_role_id once.
Error Cases
| HTTP Status | Error Code | Condition | Client Action |
|---|---|---|---|
401 | — | Missing or invalid JWT | Re-authenticate. |
403 | PERMISSION_USER_CONTEXT_MISSING | Authenticated but no active role | Select an active role, retry. |
Once the stream is open, no further HTTP error is possible on this connection — a mid-stream problem (slow consumer, shutdown, transport error, a role change) ends the response instead of returning an error status, because the response has already committed to 200.
Edge Cases
- No cap on concurrent connections per admin. Unlike the personal notification stream (capped at 5 with eviction), this route has no per-user connection limit in the current code.
- Slow consumer. If more than
REALTIME_MAX_BUFFERED_BYTES(1 MiB) is queued in the socket, the connection is closed server-side. The client'sEventSourcereconnects automatically afterretry: 3000ms;Last-Event-IDon that reconnect fills the gap from the 500-event history buffer. - Server shutdown. An in-flight connection is ended immediately as a normal
200close (never a non-200, which would makeEventSourcegive up permanently) when the process is draining for a deploy. The client reconnects against the replacement instance. - Role removed. When a staff role edit removes a role from the caller, every open stream of theirs, on every API instance, is ended through the
close-usercontrol message. The client reconnects afterretry: 3000ms with the permissions the session holds now. If the session was acting as the removed role, its active role is gone and the reconnect is refused with403 PERMISSION_USER_CONTEXT_MISSINGuntil it chooses a role; a client should send the user to the role picker rather than retry. If Redis is unreachable when the role is removed, the close is lost and logged, and the stream keeps its old filter until it next reconnects. - Active role changed while connecting. The session's active role is read again after the stream registers. If it is no longer the role whose permissions the stream resolved — removed, or switched in another tab — the stream is ended at once, and the client reconnects under the current role.
- Duplicate delivery is possible. The listener is registered before the replay read runs, so an event published in that narrow window can arrive once as a live
eventframe and once in the replay. Clients must de-duplicate onid. - History is bounded. A client away longer than 500 events (across the whole deployment, not per-user) receives only what remains in the buffer on reconnect, with no signal that more was missed. Reconcile against the underlying screens, not against the stream, for anything older.
- Reconnecting with a very old
Last-Event-ID(older than everything in the 500-event buffer) is treated the same as no filtering being possible — everything still in the buffer that the permission filter allows is replayed asmissed.
Example Requests
curl -N "$API_URL/api/admin/realtime/stream" \
-H "Authorization: Bearer TOKEN" \
-H "Accept: text/event-stream"
# Reconnecting after having last seen id 4821:
curl -N "$API_URL/api/admin/realtime/stream" \
-H "Authorization: Bearer TOKEN" \
-H "Accept: text/event-stream" \
-H "Last-Event-ID: 4821"Example raw frames as written to the wire:
retry: 3000
event: ready
data: {"at":"2026-09-24T06:00:00.000Z"}
event: history
id: 4820
data: {"id":4820,"kind":"feedback.submitted","soundClass":"soft","permission":"Feedback_READ","aggregateId":"0192f900-aaaa-7a10-b3d2-6f1e0c5a7b50","occurredAt":"2026-09-24T05:58:12.000Z","summary":"New contact message (support)"}
: keep-alive 2026-09-24T06:00:20.000Z
event: event
id: 4822
data: {"id":4822,"kind":"exam.result_published","soundClass":"positive","permission":"ExamResults_READ","aggregateId":"0192f900-bbbb-...","occurredAt":"2026-09-24T06:00:25.000Z","summary":"exam.result_published"}9. Validation and Constraints Summary
| Constraint | Value | Enforced By |
|---|---|---|
| Heartbeat interval | 20 seconds | REALTIME_HEARTBEAT_SECONDS |
| Slow-consumer buffered-bytes bound | 1 MiB (1024 * 1024) | REALTIME_MAX_BUFFERED_BYTES |
| Client reconnect delay hint | 3000 ms | REALTIME_RECONNECT_DELAY_MS |
| History buffer size (reconnect replay ceiling) | 500 events | REALTIME_HISTORY_LIMIT |
| Cold-start replay size | 200 events | REALTIME_COLD_START_LIMIT |
close-user publish timeout | 2000 ms by default, no offline queue | REALTIME_CONTROL_PUBLISH_TIMEOUT_MS (connect timeout: REALTIME_CONTROL_CONNECT_TIMEOUT_MS, also 2000 ms); see RealtimeControlPublisher |
10. Integration Checklist
- The stream is opened server-to-server (BFF pattern), never directly from a browser
EventSource, because the browser cannot setAuthorization. -
Last-Event-IDis forwarded on every reconnect the transport layer performs. -
historyandmissedframes are rendered as already-read (no toast, no sound); onlyeventframes play a sound, chosen bysoundClass. - The client de-duplicates on
idin case of a redelivery-driven duplicate. - A
403 PERMISSION_USER_CONTEXT_MISSINGis treated as "select a role," not retried blindly. - A stream ended by the server is reconnected, and a reconnect refused with
403 PERMISSION_USER_CONTEXT_MISSING— for example because the session's active role was removed — sends the user to the role picker. - The panel remains fully correct if the stream never connects — it is an enhancement layered on data the ordinary read routes already serve.
See Also
- Backend doc: /docs/developer/realtime/backend
- Features and flows doc: /docs/developer/realtime/feature
- Client guide: /docs/developer/architecture/live-updates-and-push
- Personal notification stream and push devices: /docs/developer/notification/api