# 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 YespublishSelf / 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 dedupeKey per 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 in PUBLISHER_GROUPS, or a configured X-Api-Key (api-keys.md)
  • userGuid must be the recipient’s Cognito sub (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.