CorebanqCorebanq Developer Docs
Notifications

Description

Purpose and use

Notifications deliver operational messages to users across in-app, email, SMS, and other configured channels. They keep customers and staff informed about account events, tasks, transfers, invoices, signatures, and service actions.

Who uses this. Customers, operations staff, support teams, compliance reviewers, and template administrators use notifications to receive, configure, or investigate event-driven messages.

How it works. A business event creates a notification with type, recipient, customer context, metadata, and delivery preferences. Templates and user preferences determine wording and channel delivery.

What users do. Users review notification lists, mark messages read, receive real-time updates, manage preferences, and troubleshoot missed or duplicate messages.

Outcomes and side effects. Notifications create communication history and can trigger unread counts or external delivery. They do not replace the authoritative audit trail for money movement or approval decisions.

Related manuals: Notification integration, Template admin, Users, Tasks.

Overview

The Notifications module provides a comprehensive real-time notification system for the CoreBanq platform. It supports multi-channel delivery, user preference management, and async processing through queue integration. The system enables real-time communication with users through various channels including email, SMS, and browser notifications.

Core Features

🔔 Real-Time Notifications

  • Server-Sent Events (SSE): Real-time browser notifications
  • WebSocket-like Experience: Persistent connections for instant delivery
  • Multi-User Support: Broadcast notifications to multiple users
  • Connection Management: Automatic client registration and cleanup

📡 Multi-Channel Delivery

  • System Notifications: In-app notifications via SSE
  • Email Integration: HTML/text email notifications
  • SMS Integration: Text message notifications
  • Extensible Architecture: Easy addition of new channels (WhatsApp, Telegram, etc.)

User preference management — not implemented

Both preference routes are stubs. GET and PUT /v1/notifications/preferences discard the request entirely and answer 200 {"message": "Not implemented"}. Nothing is stored, nothing is read, and the PUT does not even decode the body — a client that submits preferences gets a 200 and loses them silently. Do not build against these routes.

🔄 Queue-Based Processing

  • Async Processing: Non-blocking notification delivery
  • Message Queuing: Reliable delivery with queue persistence
  • Batch Processing: Efficient handling of multiple notifications
  • Error Recovery: Retry mechanisms for failed deliveries

🗃️ Complete State Management

  • Notification Storage: Persistent storage of all notifications
  • Read/Unread Tracking: Individual read state per user
  • Delivery Confirmation: Track delivery status across channels
  • Historical Data: Complete notification history and analytics

API Endpoints

Get User Notifications

GET /v1/notifications

Returns the shared list envelope — data, total, total_unfiltered and has_more always, keys and metadata when non-empty — scoped through the notification record type, so row-level grants apply.

That RBAC scope is the only one — these are not "the authenticated user's own rows". Neither the handler nor the queries behind it add a user_id predicate, so what a caller sees is decided entirely by GetPermittedRecords: a caller holding read_all on the notification record type — an Administrator, for instance — gets every user's notifications, and any other caller gets the rows they hold a read grant on, whoever they belong to. Pass search.user_id to narrow the list to one user.

stack turns data from an array into an object. With ?stack=read the body is "data": {"true": [...], "false": [...]} — keyed by the formatted value of the stacked field — and keys lists those group keys in the requested order. Without stack, data is the array and keys is absent entirely. stack accepts the same field names as the filter list, optionally prefixed with - for descending and suffixed with [format]; an unknown field is 400 query_m.invalid_field and an unterminated bracket is 400 query_m.invalid_format. Like sort, both refusals arrive only once rows have been fetched — a filter that matched nothing returns the empty stacked shape without parsing the value at all.

keys is not a list of field names. It exists only on the stacked path and holds the group keys of data.

Query parameters

  • limit (optional): default 10, not 50. Over 100 clamps to 100; zero or negative becomes 10; -1 is the shared skip-data-fetching sentinel. A non-integer is 400.
  • offset (optional): default 0; a negative value is clamped to 0.
  • sort (optional): a filterable field name, - for descending.
  • stack (optional): changes the shape of the response. See below.
  • distinct (optional): passed to the shared query layer.
  • customer_id (optional): not a scope. GetNotificationsSrv uses it for one thing only — picking the language for the rendered title and content, through GetUserPreferredLanguage. It filters nothing; use search.customer_id for that. Omitted, it stays the nil UUID and is passed through as such. A value that does not parse is 400 — but with common.invalid_input, because the handler discards the specific notifications_m.invalid_id the service built and calls BadRequest400 with no argument.
  • There is no status parameter. Use search.read=true or search.read=false.

Filterable fields

id, customer_id, user_id, event, title, content, rec_id, rec_type, created_at, sent_at, read, read_at.

Three traps. title and content do not hold what the user sees. Both filter against the stored columns, and CreateUserNotifications never assigns Title at all — so user_notifications.title is empty on every row and search.title can never match anything. content holds only the caller-supplied pre-rendered text, so it is empty for key-based notifications. The keys live in title_key and content_key, which are hidden from JSON and are not filterable. The title and content in the response are rendered per request, after the filter has already run — and on this route they come from the event push template whenever one resolves, not from the keys. See the section below.

title and content are resolved three different ways

The same row does not read the same on every route that returns it.

Routetitlecontent
GET /v1/notificationsPush template for the event rendered with the row's metadata; falls back to title_keySame template — replacing the stored text; falls back to stored content, then content_key
GET /v1/notifications/{id}title_key only — the template is never consultedStored content when non-empty, otherwise content_key
PATCH /{id}/readThe raw stored column, which is empty on every rowThe raw stored text, untranslated

buildInAppNotificationMessage succeeds whenever a push template exists for the event, and unconditionally for the customer-status events. So a chat notification listed and then opened shows the templated summary in the list and the stored message body on the detail view — same row, two answers.

link_url and link_text follow the same split: applyNotificationLinkFields is called from the list loop and from the SSE/channel payload builder and nowhere else, so the read-one and the mark-read always omit both, even for an event that has a link configured.

Any search. outside the list is rejected, and rejected as a 500: query.validateFieldName builds query_m.invalid_search_field without a status, and an empty Code sends HandleAppErrorWithCode to InternalServerError500. A caller error arrives wearing a server error's status.

sort is validated too, but only on the fetch pass — applyParams skips sorting when counting. So an unknown sort field is a 400 query_m.invalid_sort_field when the filter matched at least one row, and goes unnoticed when the count came back zero or limit=-1 skipped the fetch: those answer 200 with an empty page.

Response

Get Single Notification

GET /v1/notifications/{notification_id}

Retrieve a specific notification by ID.

title and content are resolved from the keys only — this route never consults the event push template, so a templated notification reads differently here than in the list, and link_url and link_text are never populated at all. See the resolution table above.

A 404 is raised after the storage read succeeds, by checking whether the returned row has the nil id, so a row the caller may not see is reported as absent rather than forbidden.

Response

Mark Notification as Read

PATCH /v1/notifications/{notification_id}/read

Marks the notification read, and only read. It is not a generic update endpoint: read is the only field the request struct declares, and everything else in the body is ignored.

The row comes back unresolved. No title_key lookup, no push template, no link fields — the response is whatever UPDATE … RETURNING produced, so title is the empty stored column and content is the raw stored text, untranslated.

read is a presence flag, not a value. Whatever it carries — true or false — the row is written SET read = true, read_at = now(). The service only nil-checks patch.Read and never passes it down, and the storage statement hard-codes the column. There is no way to mark a notification unread through this API: no other route clears the flag.

Request body — required in practice

{
  "read": true
}

An id that matches no row of yours also answers 200. The statement is UPDATE … RETURNING * scanned into a struct, and scanning zero rows is not an error — so an unknown notification_id, or one belonging to another user, comes back as a fully zero-valued notification (id all zeros, read false). There is no 404 on this route; check the returned id before trusting the result.

Omitting read is a no-op that still answers 200, with the body null. The service returns (nil, nil) when the decoded patch has no read member and the handler serialises that nil pointer, so {} and {"read": null} both write nothing and return the four bytes null. The older claim that an absent body marks the notification read is the opposite of what happens. Send true; sending false does the same thing, not the opposite.

Response

Delete Notification

DELETE /v1/notifications/{notification_id}

This route addresses a different table from every other route in the module. DeleteNotification loads models.Notification — notifications.notifications, the fan-out source row — by primary key, while the list, the read-one and the mark-read all address notifications.user_notifications, the per-recipient copy. Feeding an id taken from GET /v1/notifications back into this route therefore finds no row.

The caller's user id is not a scope. It is only written into deleted_by; the lookup filters on the primary key alone, and customer_id is not read either. Any caller the endpoint grant admits can soft-delete any notification whose id they know, and because the row flagged is the parent, the notification disappears for every recipient — GetUserNotificationByID joins the parent and filters parent.deleted = false.

The delete is logical: deleted, deleted_at and deleted_by are set and the row is saved.

Response

200, not 204, and a bare map rather than the StdResponse envelope:

{"message": "Notification deleted"}

The message is a fixed English string with no i18n key.

A missing row is a 500, not a 404. The storage layer wraps every failure of the lookup or the save — gorm.ErrRecordNotFound included — in common.database_error with no status, and an empty Code sends HandleAppErrorWithCode to InternalServerError500.

Preferences — both routes are stubs

GET /v1/notifications/preferences

PUT /v1/notifications/preferences

Neither handler reads its request or touches storage. Both answer, unconditionally:

{"message": "Not implemented"}

with status 200. The PUT does not decode the body, so submitted preferences are discarded without any indication. They cannot fail and they cannot return data.

Real-Time Connection (SSE)

GET /v1/notifications/sse

Establish a Server-Sent Events connection for real-time notifications.

Authentication: Bearer token required

Response Headers

The server sets the following on the stream:

  • Content-Type: text/event-stream
  • Cache-Control: no-cache, no-transform
  • Connection: keep-alive
  • X-Accel-Buffering: no — disables proxy buffering (nginx and similar) so frames are not held until the response ends

This route has two different 503 bodies, and the graceful-shutdown map is neither of them. health.LifecycleMiddleware skips /v1/notifications/sse, so that map never appears here. What can:

  • A draining SSE manager, answered by the handler through http.Error: Content-Type: text/plain, body the line server shutting down. No JSON at all.
  • An unhealthy auth cache. The route is registered with auth.WrapWithMiddlewares, so auth.Middleware runs ensureCacheAvailable before the handler is entered — an unreachable Redis/valkey refuses the connection with the JSON envelope and auth_m.internal_server_error, the auth package's own constant rather than the errs one that reads common.server_error.

Branch on the body shape, not on the status: the plain-text line means the stream is draining, the envelope means the cache is down.

Access-Control-Allow-Origin is echoed from the request's Origin header verbatim — on the stream itself. The handler's OPTIONS branch is dead code and no preflight ever reaches it: auth.CORSMiddleware is mounted on the root router ahead of everything and answers a preflight carrying an Origin itself — a bodiless 200 when the origin is allowed, 403 when it is not — while a preflight without an Origin header falls through to chi, which has only GET and POST registered for this pattern and answers 405.

Connection Management

  • Persistent Connections: SSE connections remain open until the client disconnects or the server shuts down
  • Keep-Alive Heartbeat: The server sends a : keepalive comment frame every notifications.sse.heartbeat_seconds (default 25s) so intermediary proxies/gateways do not close the idle connection; a failed heartbeat write also surfaces dead peers for prompt cleanup. Keep this interval below any upstream proxy idle-read timeout.
  • No Server Timeout: WriteTimeout is unset for the server so long-lived streams are not cut off; the server's IdleTimeout applies only to idle keep-alive connections between requests, not to an active stream.
  • Automatic Cleanup: Server automatically cleans up disconnected clients
  • Multi-User Support: Each user can have multiple concurrent SSE connections
  • Cross-Origin Support: CORS headers are automatically set for browser compatibility

Event Stream Format

On connect the server emits an open frame, then a message frame per notification, and periodic keep-alive comments:

event: open
data: {"message": "Connection established"}

event: message
data: {"id":"123e4567-e89b-12d3-a456-426614174000","user_id":"...","customer_id":"...","event":"message_received","title":"New Message","content":"Hello from John","rec_type":"chats","link_url":"/chat/room/456","link_text":"View Message","read":false,"created_at":"2024-01-15T10:30:00Z","sent_at":"2024-01-15T10:30:00Z","channels_attempted":null,"channels_sent":null,"channels_delivered":null,"channels_failed":null}

: keepalive

event: close
data: {"reason": "server_shutdown"}

The four channels_* fields are null on SSE frames, not []: the payload builder constructs a fresh notification and never sets those slices, and they carry no omitempty. The read routes return the stored [].

The close frame is the shutdown signal. SSEManager.Shutdown flushes event: close with {"reason": "server_shutdown"} to every connected client before cancelling them, precisely so a client can back off rather than reconnect into a draining server.

Connection Lifecycle

  1. Connect: Client establishes SSE connection with authentication; server sends the open frame
  2. Register: Server registers client for the authenticated user (missed system notifications are replayed)
  3. Receive: Client receives real-time message frames as they occur, interleaved with : keepalive heartbeats
  4. Disconnect: Client closes connection or navigates away
  5. Cleanup: Server automatically removes client from active connections

Test Notification Endpoints

Trigger Test Notification (Queue-based)

POST /v1/notifications/trigger

Development/Testing Only - Trigger a test notification through the full notification system (queue-based processing).

Authentication: API Key required

Request Body

{
  "type": "chats",
  "event": "message_received",
  "title_key": "notifications.chats.message_received.title",
  "content": "This is a test notification",
  "customer_id": "550e8400-e29b-41d4-a716-446655440000",
  "user_ids": ["123e4567-e89b-12d3-a456-426614174000"],
  "rec_type": "chats",
  "rec_id": "789e4567-e89b-12d3-a456-426614174000"
}

There is no title field on this body — the struct declares title_key, resolved at read time.

Five things about these two routes:

  • Nothing is validated. No struct validator runs and no field is checked, so an empty body decodes successfully and is processed.
  • type is a record type name, looked up in rbac.rec_types — not a kind of notification. It is consulted only when rec_type and rec_id are not both supplied.
  • rec_id is honoured only when rec_type is supplied alongside it. Otherwise it is discarded: prepareNotificationFromInput reuses one variable for both purposes, so the stored rec_id becomes the id of the record type looked up from type, not the record the notification is about. A row written that way can never be cleared by MarkUserNotificationsReadByRec, which matches on user_id + rec_id + rec_type — its badge will not go away when the user opens the record.
  • user_ids is unioned with every connected SSE client, not used instead of them. Supplying it does not stop connected users receiving the notification. With no connected clients and no user_ids, the route answers 200 {"message": "No connected users to notify"} having done nothing.
  • is_system is decoded and then dropped. prepareNotificationFromInput does not copy it into the notification it builds, so it has no effect on either route.

An unknown type answers 500, not 404. The service builds the error WithCode(404), but the handler routes it through the deprecated HandleAppError, which switches on Params["errorCode"] rather than on Code. Nothing sets that param, so the switch falls to default and writes a 500 carrying common.record_not_found — a 404 in disguise.

Response

{
  "message": "Notification sent to all connected users"
}

Send Direct SSE Notification

POST /v1/notifications/sse

Development/Testing Only - Send a notification directly to SSE clients (bypasses queue processing).

Authentication: API Key required

Request Body

Same as trigger endpoint above.

Response

{
  "message": "Notification sent to all connected users"
}

Errors

The envelope

{"status": 404, "message": "Notification not found", "code": "notifications_m.not_found", "class": "business"}

message is the resolved i18n template for code. code, class, retryable and details are stamped only outside the 2xx range. details is never populated by this module — nothing under modules/notifications calls WithDetails. ClassForStatus has no empty branch: 400 and 422 are validation; 408, 429, 502, 503 and 504 are temporary and additionally carry retryable: true; everything else, 500 included, is business.

Three responses on these routes are not the envelope at all: the two API-key test routes and the delete return a bare {"message": "..."} map with no status field; the SSE stream is text/event-stream; and the SSE shutdown refusal is text/plain.

Codes these routes can produce

StatusCodeWhen
400notifications_m.invalid_idnotification_id did not parse as a UUID
400notifications_m.invalid_user_idNo user id in the request context — SSE and delete
400notifications_m.failed_to_updateThe mark-read body did not decode. Names an update that was never attempted
400common.invalid_inputcustomer_id did not parse, or limit/offset was not an integer. The customer_id case loses its specific code because the handler discards the service's AppError
400common.failed_to_serializeThe body of a test route did not decode
400query_m.invalid_sort_fieldsort named a field outside the filterable list. Raised on the fetch pass only, so it does not fire when the count came back zero
400query_m.invalid_fieldstack named a field outside the filterable list. Fetch pass only, same as sort
400query_m.invalid_formatstack left its [format] bracket unterminated. Fetch pass only
404notifications_m.not_foundRead-one found no row. Raised after the storage read succeeds, by checking whether the returned row has the nil id — so a row the caller may not see is reported as absent rather than forbidden
500common.record_not_foundA 404 in disguise on the two test routes: type named a record type rbac.rec_types does not have. See the test-endpoint section for why the status is wrong
500query_m.invalid_search_fieldA 400 in disguise on the list: search named a field outside the filterable list. The shared validator omits WithCode, and an empty Code sends HandleAppErrorWithCode to InternalServerError500
500common.database_errorAny storage failure on the list, the read-one, the mark-read or the delete. The storage layer builds it without WithCode, so the empty Code routes to InternalServerError500. On the delete this is also where a row that does not exist lands
500common.rbac_failed_to_check_permissionThe RBAC read-scope lookup failed on the list. GetPermittedRecordsCtx stamps WithCode(500) explicitly
500common.server_errorThe send failed on a test route

The notifications_m.* constants also include failed_to_mark_as_read, failed_to_delete, failed_to_clear, id_should_be_nil, db_not_initialized, queue_store_not_initialized, service_init_failed, and the group/channel/event and preference families (invalid_group, invalid_channel, invalid_event, failed_to_parse_group, failed_to_parse_channel, failed_to_create_preference, failed_to_update_preferences, failed_to_fetch_preferences, failed_to_fetch_notifications). None of the preference codes can reach a client, because both preference handlers are stubs that never fail; the rest come from the storage and processing layers rather than from these nine handlers directly.

Rate limiting

auth.RateLimitMiddleware is mounted on the root router, above every route in the service — but only when the AppConfig flag rate_limits.rate_limits_switcher is true. reads that flag once at bootstrap and calls r.Use inside the if, so with the flag off the middleware is absent from the chain entirely — not mounted and idle — and no route can answer 429. Changing the flag takes a restart. When it does fire the 429 code is not common.too_many_requests — that key is only the fallback the reply helper stamps when called with no AppError. The codes that really arrive are rate_limits_m.exceeded, rate_limits_m.global_exceeded and rate_limits_m.failed_to_increment_ip_limit, plus two free-text strings that are not i18n keys at all: rate limit exceeded and Global rate limit exceeded.

Two different authentication chains

Seven routes use auth.WrapWithMiddlewares (bearer token). POST /v1/notifications/trigger and POST /v1/notifications/sse use auth.APIKeyMiddleware instead — it replaces the chain rather than wrapping it, so those two have no user context, no RBAC check and no licence check, and their refusals are different:

StatusCodeCause
401common.api_key_requiredX-API-Key header absent or empty
403common.invalid_api_keyThe key is not in the loaded key set

auth.APIKeyMiddleware has an OPTIONS bypass, but it is unreachable for the same reason as the one on the SSE handler: auth.CORSMiddleware answers the preflight first when an Origin is present, and chi answers 405 when it is not.

Their 503s differ from each other, too. POST /v1/notifications/trigger can answer the graceful-shutdown map, because health.LifecycleMiddleware does not skip that path. POST /v1/notifications/sse cannot answer 503 at all: the middleware skips /v1/notifications/sse on the path alone, with no method test, so the shutdown map is out; and auth.APIKeyMiddleware compares a header against the loaded key set and never calls ensureCacheAvailable, so the auth-cache envelope is out as well. Its failures arrive as the 500 written by InternalServerError500.

Refusals the middleware writes before the handler runs — bearer routes

Produced by the root router's chain — health.LifecycleMiddleware first, then auth.Middleware — not by module code.

StatusCodeCause
401common.unauthorizedNo bearer token, one that does not parse, a blacklisted token, or a cache error during the blacklist lookup
403common.rbac_no_rec_access → No access to the recordrbac.CanCallAPIv0 denied the endpoint grant. Forbidden403 is called with no AppError, so the body carries the helper's default code
403license_m.license_invalid, license_m.license_expired, license_m.module_not_licensed, license_m.license_key_missingThe licence branch. module_not_licensed means the tenant's licence does not cover this module: its routes exist in the binary and are refused. license_key_missing is not a tenant problem — it means licenseService was nil, so the server came up without a usable COREBANQ_LICENSE_KEY and refuses every route until it is restarted. A fifth code, license_m.license_service_unavailable, is matched by the middleware but is written to the licence-error context by no code path, so it never reaches a client
503{"overall_status": "unhealthy", …} — not the envelopeGraceful shutdown, and this one comes first. health.LifecycleMiddleware is mounted with r.Use on the root router, so it precedes authentication and every handler — except on the path /v1/notifications/sse, which the middleware skips. It skips on the path alone, with no method test, so both the GET and the POST on that path are exempt
503auth_m.internal_server_error → Internal server errorThe auth cache is unhealthy. ensureCacheAvailable runs before the blacklist lookup, so an unreachable Redis/valkey refuses every authenticated request. The code is the auth package's own MsgInternalServerError, not errs.MsgInternalServerError (common.server_error)

The 503 is three different bodies in this module. While the server is draining, health.LifecycleMiddleware writes a bare map — {"overall_status": "unhealthy", "message": "Service is shutting down", "timestamp": "…"} — with no status, code, class or retryable field, and a fixed English message that Accept-Language does not translate. The auth-cache 503 is the envelope, but its code is auth_m.internal_server_error, not common.server_error: ensureCacheAvailable calls errs.New(MsgInternalServerError) unqualified from inside package auth, so the constant that resolves is the auth package's own. Both render the same English message, so only the code distinguishes it from a genuine 500. And on GET /v1/notifications/sse, which the shutdown middleware skips, a draining SSE manager answers text/plain with the line server shutting down — while the auth-cache envelope still reaches that route, because auth.Middleware runs in front of it either way.

Best Practices

Performance Guidelines

  1. Connection Limits: Implement per-user SSE connection limits
  2. Message Batching: Process notifications in batches for efficiency
  3. Queue Management: Monitor queue depths and processing rates
  4. Database Optimization: Use appropriate indexes for notification queries

Security Guidelines

  1. Authentication: Always verify user identity for notification access
  2. Data Sanitization: Sanitize notification content before delivery
  3. Audit Logging: Log all notification operations for security auditing
  4. Rate Limiting: Implement rate limits for notification creation

User Experience Guidelines

  1. Graceful Degradation: Handle SSE connection failures gracefully
  2. Progressive Enhancement: Provide fallback for users without SSE support
  3. Clear Messaging: Use clear, actionable notification content
  4. Preference Respect: Always honor user notification preferences

Dependencies

Required Services

  • Database: PostgreSQL for notification storage
  • Queue: Message queue system (local or AWS SQS)
  • Storage: CoreBanq storage abstraction
  • RBAC: Role-based access control system

External Integrations

  • Email Service: For email notifications
  • SMS Service: For text message notifications
  • Authentication: User authentication and authorization

Configuration Dependencies

  • notifications.yaml: Notification configuration
  • queue.yaml: Queue system configuration
  • Database migration scripts for notification tables

The Notifications module provides a robust, scalable foundation for real-time user communication in the CoreBanq platform, with comprehensive features for multi-channel delivery, user preference management, and reliable message processing.

On this page