# Publishing
Publishing means creating a notification for a user. There are two safe paths — pick the right one.
# Can an app use a user’s JWT to publish?
| Goal | User access token (normal login)? |
|---|---|
| Notify themselves | Yes — publishSelf / notification.* / POST /v2/notifications/self |
| Notify someone else | No — ordinary users get 403. Use an API key or M2M token. |
| Notify someone else as an admin | Only if that user is in a Cognito group listed in Worker PUBLISHER_GROUPS (optional; usually omit) |
Do not put a publisher API key or M2M client secret in the browser. Self-notify with the user’s JWT is the browser-safe path.
flowchart TD
Q{"Who is the recipient?"}
Q -->|"The signed-in user"| Self["publishSelf / notification.success"]
Q -->|"Someone else"| Pub["publish with publisher token"]
Self --> API1["POST /v2/notifications/self"]
Pub --> API2["POST /v2/notifications"]# Path A — Notify the current user (browser OK)
Use when the action happens in the SPA and the inbox item is for me:
await notification.success('Export ready', 'Your file is ready.', '/exports/1');
// or:
await notificationsClient.publishSelf({
title: 'Export ready',
body: 'Your file is ready.',
severity: 'success',
actionUrl: '/exports/1',
});
- Any valid user token works
- Recipient is forced to token
sub— cannot target another user - Convenience helpers add a unique
dedupeKeyper call
# Path B — Notify another user (backend)
Use for jobs, webhooks, and server workflows (“Assess finished a review for surgeon X”).
A normal user JWT cannot do this (403). Use one of:
- Publisher API keys (
X-Api-Key) — easiest - Cognito M2M client-credentials token with
notifications/publish - (Optional) a user JWT only if that person is in
PUBLISHER_GROUPS(admin-style; most teams skip this)
SPA triggering cross-user notify? Do not put the API key in the browser. Call your backend / edge route; that service holds the key and calls Notifications. See App integration and the samples under examples/publish-proxy-*.
If “resource server / M2M / client secret” is new vocabulary, read Cognito explained first — then the how-to in cognito-setup.md. For production change controls (checklists, rotation, rollback), see Cognito M2M reference.
await notificationsClient.publish({
userGuid: recipientSub, // Cognito sub of the recipient
title: 'Review complete',
body: 'Your assessment feedback is ready.',
severity: 'success',
actionUrl: `/video/${videoId}/feedback`,
dedupeKey: `review_case:${caseId}`,
metadata: { caseId, videoId },
});
Requirements:
- Credentials must be a publisher: Cognito scope in
PUBLISHER_SCOPES/ group inPUBLISHER_GROUPS, or a configuredX-Api-Key(api-keys.md) userGuidmust be the recipient’s Cognitosub(same value they authenticate with)
# Getting an M2M token
PowerShell (Windows):
$ClientId = "…" # Cognito app client → Client ID
$ClientSecret = "…" # Cognito app client → Client secret
$CognitoDomain = "https://<prefix>.auth.<region>.amazoncognito.com" # App integration → Domain
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"
Use curl.exe (PowerShell’s curl is not curl). Bash/macOS: see cognito-setup.md.
Cache until expires_in. Don’t fetch a token per notification.
Wire it into the client:
const client = new NotificationsClient({
baseUrl: 'https://notifications.totumcloud.com',
getAccessToken: () => getCachedM2MToken(),
});
# Always set dedupeKey for jobs
dedupeKey is a label you invent for “this notification” so sending it twice doesn’t create two inbox items.
# The problem it solves
Jobs and HTTP calls fail and get retried all the time (timeouts, 429, process restarts). Without a label, each retry looks like a new notification → the user sees duplicates.
With a stable label like review_case:8d2e:
| Attempt | Result |
|---|---|
| First time | Creates the notification (201) |
| Same key again | Keeps the original; responds 200 with duplicate: true (treat as success) |
The service matches on who it’s for + which app sent it + your dedupeKey. You don’t send “which app” yourself — that’s taken from your API key / M2M token.
# What to put in the key
Use something stable from your domain:
dedupeKey: `review_case:${caseId}`
dedupeKey: `export:job:${jobId}`
dedupeKey: `invite:${inviteId}`
- Same real-world event → same string every retry
- Different events → different strings
- Don’t use
crypto.randomUUID()for job publishes (a new UUID each attempt defeats the point)
# Client behaviour
Without dedupeKey |
With dedupeKey |
|
|---|---|---|
| Two retries | Can create two rows | Still one row |
| npm client auto-retry on 5xx / network / 429 | Off (unsafe) | On (safe) |
Browser convenience helpers (notification.success(...)) generate a unique key per call automatically. Backend jobs must set their own.
# Responses (both are success)
| HTTP | Meaning |
|---|---|
201 + duplicate: false |
Created |
200 + duplicate: true |
Already had this key — original notification returned |
# Plain HTTP (no client)
curl -X POST https://notifications.totumcloud.com/v2/notifications \
-H "Authorization: Bearer $M2M_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"userGuid": "cognito-sub-here",
"title": "Review complete",
"body": "Your feedback is ready.",
"severity": "success",
"actionUrl": "/video/abc/feedback",
"dedupeKey": "review_case:8d2e"
}'
# Field reference (publish body)
| Field | Required | Notes |
|---|---|---|
userGuid |
yes (cross-user only) | Recipient Cognito sub. Omitted on /self |
title |
yes | Max 200 chars |
body |
yes | Max 2000 chars |
severity |
no | Default info |
actionUrl |
no | App-relative or absolute deep link |
dedupeKey |
Yes for jobs | Your label for this notification (max 256). Same key → no second row. See Always set dedupeKey |
metadata |
no | Opaque JSON, ≤ 8 KB |
source is never client-supplied — derived from the token.
Unknown fields are rejected (400) on the V2 API. Put extra context in metadata.
# Security reminders
- Never ship M2M client secrets to the browser
- Don’t grant every user the publish scope “so the SPA can notify anyone”
- Prefer Path A for browser self-notifies; Path B for trusted servers
Published traffic is rate-limited — see Rate limiting (and always set dedupeKey so client 429 retries stay safe).
Next: V1 compatibility if you still have /api/Notification/* callers.