# Troubleshooting

Friendly fixes for the problems people actually hit.


# Quick decision tree

flowchart TD
  Start["Something's wrong"] --> Auth{"401 or 403?"}
  Auth -->|401| T401["Token missing / expired / wrong issuer"]
  Auth -->|403| T403["Publishing without publisher rights"]
  Auth -->|no| Empty{"Empty inbox?"}
  Empty -->|yes| Guid["userGuid ≠ reader's Cognito sub"]
  Empty -->|no| Dup{"Duplicates?"}
  Dup -->|yes| Key["Missing or unstable dedupeKey"]
  Dup -->|no| Live{"UI feels stale?"}
  Live -->|yes| Poll["Polling is ~30s — not WebSockets"]
  Live -->|no| Other["Check network tab + problem+json detail"]

# Authentication & authorization

# 401 Unauthorized

  • getAccessToken returned null / undefined
  • Token expired and refresh didn’t run
  • AUTH_ISSUER on the Worker doesn’t match the token’s iss
  • Local .dev.vars still has the placeholder issuer

Try: decode the JWT (jwt.io) and compare iss / exp with Worker config.

# 403 Forbidden on publish

  • Using a normal user token against POST /v1/notifications
  • M2M client missing notifications/publish scope
  • PUBLISHER_SCOPES / PUBLISHER_GROUPS misconfigured on the Worker

Try: self-notify via notification.success / publishSelf, or fix the M2M client.

# 404 on mark read / delete

  • Notification id doesn’t exist
  • It belongs to another user (the API returns 404 rather than 403 to avoid leaking existence)

# Empty inbox / “I published but they don’t see it”

  1. Confirm publish returned 201 (or 200 duplicate)
  2. Compare notification.userGuid with the reader’s token sub — must match exactly
  3. Confirm the reader is hitting the same environment (staging vs prod base URL)
  4. If using V1 CreatePayload templates, confirm V1_PAYLOAD_TEMPLATES is set

# Duplicates

Cause Fix
Job retries without dedupeKey Always set a stable domain key
Key changes on every attempt Use something derived from the business id
Two different M2M clients, same logical event Dedupe is per source — pick one publisher identity
Convenience API called twice intentionally Expected — each call gets a unique key

Remember: without dedupeKey, the npm client won’t retry publish (on purpose).


# “Realtime isn’t realtime”

That’s expected. Default freshness is ~30 seconds, plus:

  • Hidden tabs don’t poll
  • Visible tabs refresh immediately on focus

Turn the interval down if product needs it (intervalMs: 10_000), understanding cost vs freshness. True push isn’t in V2 yet.


# Badge count looks wrong

List responses include total unreadCount in the ETag payload. If you’re still on a very old client that called unreadCount() only after list changes, upgrade the client.

Also check optimistic UI in the React hook — the next poll reconciles.


# V1 envelope callers broken after cutover

  • Base URL updated?
  • Publisher token instead of old API key?
  • Still expecting soft-delete restore? (not supported — see V1 compatibility)

Inspect errors[] in the envelope and the HTTP status.


# Local wrangler dev weirdness

  • Memory store resets every restart
  • Placeholder AUTH_ISSUER → all authed calls 401 until you set a real issuer
  • Open http://localhost:8787/health first

# How to read an error

V2 routes return problem+json:

{
  "type": "about:blank",
  "title": "Forbidden",
  "status": 403,
  "detail": "Publishing requires a publisher group or scope"
}

In the client: err instanceof NotificationsApiErrorerr.status, err.problem.


# Still stuck?

  1. Hit /health — is storage up?
  2. Compare behaviour in /docs Swagger with the same token
  3. Check Worker logs in the Cloudflare dashboard
  4. Skim invariants in CLAUDE.md / DESIGN.md

For extending the system itself, continue to Developer reference.