Notification channels
Users can register multiple delivery channels (webhook, Telegram, Slack, Discord, email) and bind alert rules to them. Digest and brief notifications use the same story pool and editorial guardrails documented in News Digest and Briefing Methodology.GET /api/notification-channels
Lists the caller’s registered channels and alert rules.
POST /api/notification-channels
Action-dispatched writer. The body’s action field selects the mutation:
All actions require Clerk bearer + PRO, and PRO here specifically means a billed entitlement row at
tier >= 1. A Clerk session whose role is pro but which has no entitlement row does not qualify, unlike the gateway’s tier-1 REST gate: notification delivery is enforced a second time inside Convex (assertProEntitlement), so the edge gate returns the clean 403 pro_required rather than letting the request fail deeper with a less useful error. Invalid actions return 400 Unknown action. Requests are forwarded to Convex via RELAY_SHARED_SECRET.
A caller without a billed row gets 403 pro_required only when the entitlement is confirmed non-Pro. When entitlement verification is itself in doubt the gate follows the shared billing-verification contract instead: 503 with Retry-After and X-Billing-Verification for entitlement_verification_unavailable / renewal_verification_pending / renewal_verification_failed, and 403 subscription_lapsed for a confirmed lapse. See Error handling.
Clients should honor that 503 with at least one retry at the advertised Retry-After before surfacing a failure — the dashboard’s own service layer (src/services/notification-channels.ts) makes exactly one bounded retry. For entitlement_verification_unavailable specifically, retrying earlier than Retry-After is wasted: that answer is briefly negative-cached server-side, so an early retry is served the same result. The two renewal_verification_* codes are not negative-cached, but their delay reflects a real provider re-check or cooldown, so retrying early is still answered from the same state.
- Idempotency: optional
Idempotency-Keysupported onPOST /api/notification-channels. Retrying the same key with an identical body replays the original response instead of applying the channel action again.
Webhook delivery contract
When an alert fires, registered webhook URLs receive:- Method:
POST - Headers:
Content-Type: application/jsonX-WM-Signature: sha256=<HMAC-SHA256(body, channelSecret)>X-WM-Delivery-Id: <ulid>X-WM-Event: <event-name>
- Body (envelope v1):
hmac_sha256(rawBody, channelSecret) == X-WM-Signature[7:].
POST /api/notify
Authenticated event-publish endpoint for PRO callers. Requires Clerk bearer auth and an active PRO entitlement, then enqueues the accepted event into the notification queue. Relay-internal control events such as flush_quiet_held and channel_welcome are reserved and rejected.
Errors: 401 (missing/invalid JWT), 403 pro_required — and, like every Pro-gated endpoint here, the shared billing-verification contract when entitlement verification is itself in doubt: 503 with Retry-After and X-Billing-Verification for entitlement_verification_unavailable / renewal_verification_pending / renewal_verification_failed, and 403 subscription_lapsed for a confirmed lapse. See Error handling.
- Idempotency: optional
Idempotency-Keysupported. Retrying the same key with an identical body replays the original enqueue response instead of publishing the notification again.
Caller-submitted field policy
payload.title, payload.source, payload.link, payload.url and payload.description reach the email subject and body, the Telegram/Slack/Discord text, and the web-push click target, so they are shaped at this boundary before the event is queued (issue #8397). Fields are neutralised individually — an event is never rejected for its content:
title— control characters, newlines and invisible formatting are stripped, the value is truncated to 200 characters, clickable URL tokens are replaced with[link removed], and the result is prefixedCommunity alert:so caller copy is never rendered as WorldMonitor’s own.source— preserved as submitted, unless it impersonates a first-party identity (matched after Unicode normalisation, confusable folding and punctuation stripping), in which case it becomes the neutral labelCommunity alert. Real publisher attribution such asReutersis kept.link/url— must behttpswith no embedded credentials; anything else becomes the dashboard URL. An off-origin article link is delivered to text channels with its destination host disclosed inline (<url> (source: <host>)), and is never used as the web-push click target, which stays first-party.description— same shaping astitle, truncated to 400 characters.importanceScoreandcorroborationCountare stripped; they are computed server-side.
200 response carries warnings when any field was rewritten, so a caller can detect the change instead of discovering it from a malformed notification:
Telegram
GET /api/telegram-feed
First-party browser path for the topic-tabbed Telegram Intel panel. Accepts limit, topic, and channel; there is no per-user (userId) form. Requires the dashboard session credential (wms_) and responds private, max-age=30 — it is not publicly cacheable, and an uncredentialed request returns 401 with no-store. Message text is R4.
For programmatic and partner access use the authenticated RPC GET /api/intelligence/v1/list-telegram-feed instead.
YouTube
GET /api/youtube/embed?videoId=...
Hosted YouTube iframe wrapper retained for compatibility. The current web app embeds YouTube directly; the desktop app uses the local sidecar’s /api/youtube-embed route to handle WKWebView autoplay restrictions. Neither uses this hosted route.
origin (the YouTube player origin) accepts enumerated app origins, team-pinned Vercel previews, localhost / 127.0.0.1, and tauri://localhost. parentOrigin (the iframe postMessage target) accepts that same list plus http(s)://tauri.localhost and matching single-label *.tauri.localhost hosts. Vendor subdomains such as clerk.worldmonitor.app and abacus.worldmonitor.app remain excluded. A parent-only Tauri URL supplied as origin is rejected and falls back to the apex origin (worldmonitor.app).
GET /api/youtube/live?channel=<handle> or ?videoId=<11-char-id>
Names a YouTube video for channel management. videoId (11-char YouTube id) returns the video’s title and author (channelName) from YouTube oEmbed, fetched directly; the response is cached 1 hour. If oEmbed fails, the response is 200 with null title and channelName and is not cached. When both params are sent, the videoId lookup answers.
Channel live detection is retired. A valid channel (handle with or without @, or a UC… channel id) without videoId returns 410 {"error":"channel_live_detection_retired"}, cached 1 day, with no request to YouTube or the Railway relay. At least one of the two params is required; returns 400 Missing channel or videoId parameter otherwise, and 400 for a malformed handle, channel id or video id.
Slack integration
POST /api/slack/oauth/start
Authenticated (Clerk JWT + PRO). Body is empty. Server generates a one-time CSRF state token, stores the caller’s userId in Upstash keyed by that state (10-min TTL), and returns the Slack authorize URL for the frontend to open in a popup.
pro_required, 503 (OAuth not configured or Upstash unavailable). A 503 here can also be the retryable billing-verification denial — entitlement_verification_unavailable / renewal_verification_pending / renewal_verification_failed, carrying Retry-After and X-Billing-Verification; a confirmed lapse is 403 subscription_lapsed. Branch on the code field rather than the status alone, since the misconfiguration 503 is not retryable. See Error handling.
GET /api/slack/oauth/callback
Unauthenticated — the popup lands here after Slack redirects. Validates the state token, exchanges code for an incoming-webhook URL, AES-256-GCM encrypts the webhook, and stores it in Convex. Returns a tiny HTML page that postMessages the opener and closes.
Discord integration
POST /api/discord/oauth/start
Authenticated (Clerk JWT + PRO). Same shape as the Slack start route — returns { oauthUrl } for a popup, and the same error set, including the billing-verification 503/403 codes described there.
GET /api/discord/oauth/callback
Unauthenticated. Exchanges code, stores the guild webhook, and postMessages the opener.