# Agent brief — Inovus Notifications V2
For app developers: copy this entire document into your coding agent’s context (or save it as
AGENT.md/ a Cursor rule in the consuming app). It is self-contained. Do not invent APIs that are not listed here.
# Mission
Integrate Inovus Notifications V2 — a standalone persistent in-app notification inbox. Apps publish notifications to users; users read/manage their own inbox via a typed npm client.
Production base URL: https://notifications.totumcloud.com
Interactive API docs: https://notifications.totumcloud.com/docs
Package: @inovus-medical/notifications
# What this is / is not
| This service | Not this service |
|---|---|
| Persistent inbox (survives refresh) | Toast / snackbar UI |
| Badge unread count + mark read/delete | Bell dropdown styling / branding |
Optional actionUrl deep link field |
Router navigation (app owns that) |
| ~30s ETag polling by default | WebSocket / SSE “instant” realtime |
| Cognito JWT auth | New login system |
Toasts stay in the app. Use a toast for ephemeral “Saved!” feedback. Use this service when the user should still see the message later in a bell/inbox.
# Install
npm install @inovus-medical/notifications
# or: pnpm add @inovus-medical/notifications
React is an optional peer — only required for @inovus-medical/notifications/react.
# One-time wiring (do this once per app)
// lib/notifications.ts
import {
NotificationsClient,
createNotificationService,
} from '@inovus-medical/notifications';
export const notificationsClient = new NotificationsClient({
baseUrl: 'https://notifications.totumcloud.com', // or staging URL
// MUST return the current Cognito access token (sync or async).
// Called per request; called again once after 401 for refresh.
getAccessToken: () => auth.getValidAccessToken(),
});
/** V1-shaped helpers for self-notifications (current user only). */
export const notification = createNotificationService(notificationsClient);
Invariants for the agent:
- Construct one client; inject the app’s existing token getter — do not invent token storage.
- Never put Cognito M2M client secrets in browser code.
- Call
stop()fromsubscribe()on logout (React hook cleans up on unmount).
# Auth model (critical)
| Caller | Token | Can do |
|---|---|---|
| End user (SPA) | User access token | List/manage own inbox; publishSelf / notification.success(...) to self only |
| Backend / job | M2M token with scope notifications/publish |
publish({ userGuid, ... }) to any user |
| Optional admin | User token in a Cognito group listed in PUBLISHER_GROUPS |
Same as backend publish |
Worker env:
-
PUBLISHER_SCOPES=notifications/publish— normal / required for M2M. Matches tokenscope. You create a Cognito resource server scope, not a user group. -
PUBLISHER_GROUPS=…— optional. Only if humans publish with user logins; then you create that Cognito group. Omit if unused. -
Inbox ownership key = Cognito JWT
sub. PublishuserGuidmust equal thatsubor the recipient will never see the row. -
sourceis server-derived from the token (client_idorsub). Never sendsourcein the body. -
Ordinary users get 403 on
POST /v1/notifications(cross-user). Use/selfor the convenience API instead.
# Consume — inbox
# List / mutate
import { notificationsClient } from './lib/notifications';
const { items, nextCursor, unreadCount } = await notificationsClient.list({
limit: 50,
unreadOnly: true,
});
await notificationsClient.markRead(id);
await notificationsClient.markUnread(id);
await notificationsClient.markAllRead();
await notificationsClient.delete(id);
const count = await notificationsClient.unreadCount();
list returns total unreadCount (not page-limited). Pagination via opaque nextCursor. Max limit 200.
# Subscribe (polling)
const stop = notificationsClient.subscribe(
({ items, unreadCount }) => {
/* update bell UI */
},
{ intervalMs: 30_000 },
);
stop(); // ALWAYS on logout
Behaviour: ETag/304 when unchanged; pauses when tab hidden; refreshes on visibility. This is polling, not WebSockets.
# React
import { useNotifications } from '@inovus-medical/notifications/react';
import { notificationsClient } from './lib/notifications';
const {
notifications,
unreadCount,
loading,
error,
markRead,
markUnread,
markAllRead,
remove,
} = useNotifications(notificationsClient);
App owns markup. On row click: markRead(id) then navigate if actionUrl is set.
# Self-notify (browser-safe)
When the current user should get an inbox item:
import { notification } from './lib/notifications';
await notification.success('Export ready', 'Your CSV is ready.', '/exports/1');
await notification.error('Failed', error.message);
await notification.warning('Warning', 'Check this');
await notification.info('Info', 'FYI');
// or: notification.notify('success', title, body, url?)
Equivalent low-level API:
await notificationsClient.publishSelf({
title: 'Export ready',
body: 'Your CSV is ready.',
severity: 'success',
actionUrl: '/exports/1',
// dedupeKey optional; convenience API supplies a unique per-call key
});
Recipient is always token sub. Cannot target another user.
# Publish to another user (backend / M2M only)
await notificationsClient.publish({
userGuid: recipientCognitoSub, // MUST be Cognito sub
title: 'Review complete',
body: 'Your assessment feedback is ready.',
severity: 'success', // 'info' | 'success' | 'warning' | 'error'
actionUrl: `/video/${videoId}/feedback`,
dedupeKey: `review_case:${caseId}`, // ALWAYS set for jobs
metadata: { caseId, videoId }, // optional, ≤ 8 KB JSON
});
# dedupeKey rules (do not ignore)
- Idempotency key scoped as
(userGuid, source, dedupeKey)in the DB. - Always set a stable domain key for jobs (
review_case:{id},export:job:{id}, …). - Same key →
200+{ duplicate: true }+ original row (treat as success). - New key →
201+{ duplicate: false }. - Without
dedupeKey, the client will not auto-retry publish (prevents accidental duplicates). Convenience self-notify helpers add their own unique key per call.
# M2M token (server)
$ClientId / $ClientSecret come from Cognito → user pool → App integration → App clients → (confidential client with a generated secret). Token host is App integration → Domain (not the issuer URL). Full console steps: docs/cognito-setup.md in the notifications repo.
PowerShell (Windows) — use curl.exe:
$ClientId = "…"
$ClientSecret = "…"
$CognitoDomain = "https://<prefix>.auth.<region>.amazoncognito.com"
curl.exe -X POST "$CognitoDomain/oauth2/token" `
-H "Content-Type: application/x-www-form-urlencoded" `
-u "${ClientId}:${ClientSecret}" `
-d "grant_type=client_credentials&scope=notifications/publish"
Cache until expires_in. Wire via getAccessToken on a server-side client instance.
# Wire types (camelCase)
type Severity = 'info' | 'success' | 'warning' | 'error';
interface Notification {
id: string; // UUID
userGuid: string;
source: string;
title: string;
body: string;
severity: Severity;
actionUrl: string | null;
dedupeKey: string | null;
metadata: Record<string, unknown>;
read: boolean;
readAt: string | null;
createdAt: string; // ISO
}
Limits (approximate): title ≤ 200, body ≤ 2000, dedupeKey ≤ 256, metadata ≤ 8 KB. Unknown fields on V2 bodies → 400.
# HTTP cheat sheet (V2)
| Method | Path | Who |
|---|---|---|
POST |
/v1/notifications |
publisher → any user |
POST |
/v1/notifications/self |
any user → self |
GET |
/v1/notifications |
own inbox (+ unreadCount, ETag) |
GET |
/v1/notifications/unread-count |
badge |
POST |
/v1/notifications/{id}/read |
own |
POST |
/v1/notifications/{id}/unread |
own |
POST |
/v1/notifications/mark-all-read |
own |
DELETE |
/v1/notifications/{id} |
own |
GET |
/health |
public |
Errors: RFC 9457 application/problem+json. Client throws NotificationsApiError with status + problem.
# V1 compatibility (only if migrating)
Two different things:
- Legacy HTTP
/api/Notification/*— still served; often only base URL changes; Create needs a publisher Cognito token (no shared API keys). Envelope{ value, errors, warnings }. - Legacy façade
notification.success(title, message, url?)— reimplement withcreateNotificationService(client)as shown above. Keeptoast.*in the app.
Do not mix V1 envelopes with V2 client calls.
# Client options
| Option | Default | Notes |
|---|---|---|
baseUrl |
required | No trailing slash needed |
getAccessToken |
required | string | null | Promise<…> |
timeoutMs |
10000 |
Per attempt |
maxRetries |
3 |
429/5xx/network; publish only if dedupeKey set |
retryBaseDelayMs |
250 |
Full jitter backoff |
fetch |
globalThis.fetch |
Injectable for tests |
Package exports: NotificationsClient, createNotificationService, NotificationsApiError, types, and useNotifications from /react.
# Do / don’t checklist for the agent
Do
- Use
@inovus-medical/notificationsrather than rawfetchunless required. - Set
dedupeKeyon every backend publish. - Match
userGuidto Cognitosub. - Own the bell UI and toast UI in the app.
- Unsubscribe polls on logout.
- Prefer
notification.*/publishSelffor browser self-notifies.
Don’t
- Grant every end-user the
notifications/publishscope so the SPA can notify arbitrary users. - Ship M2M secrets to the browser.
- Expect WebSocket realtime.
- Send
sourceor unknown fields on V2 publish bodies. - Build toast UI inside this integration “because the old quick-start had toastService”.
- Retry unkeyed publishes manually in a loop without a stable key.
# Common failures → fix
| Symptom | Fix |
|---|---|
401 |
Token missing/expired; fix getAccessToken / refresh |
403 on publish |
Need M2M publisher token, or use /self |
| Empty inbox after publish | userGuid ≠ reader’s sub, or wrong environment base URL |
| Duplicates | Missing/unstable dedupeKey |
| “Not live” | Polling ~30s by default — expected |
# Minimal acceptance criteria
- [ ] Client constructed with production/staging
baseUrl+ real token getter - [ ] Bell (or equivalent) lists items + unread count; mark read / delete work
- [ ] Subscribe or
useNotificationsused; cleaned up on logout/unmount - [ ] Backend jobs publish with M2M token + stable
dedupeKey - [ ] Browser self-notifies use convenience API or
publishSelf(not cross-user publish) - [ ] Toasts remain a separate app concern
End of agent brief. Prefer this document over inventing APIs. Human docs: user guide under docs/userguide/ in the notifications repo.