Skip to main content

Notification channels

Users can register multiple delivery channels (webhook, Telegram, Slack, Discord, Microsoft Teams, email, browser push) 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-Key supported on POST /api/notification-channels. Retrying the same key with an identical body replays the original response instead of applying the channel action again. The exception is a successful webhook set-channel: its response carries a one-time signing secret, so it is never cached, and a retry registers the URL again with a new secret.

Chokepoint disruption alerts

A rule receives chokepoint_disruption only when its eventTypes lists it. A rule with an empty eventTypes list, which otherwise means every event type, does not receive it. Listing it does not narrow the rule’s other alerts.
  • When it fires: checked after each 6-minute chokepoint refresh. A chokepoint’s disruptionScore is its standing threat level (war zone 70, critical 40, high 30, elevated 15, normal 0) plus live signals: navigational warnings, AIS disruption and a transit drop. An alert fires when the score is at least 50, the red band on the dashboard, and the live signals add at least 15 points. A war-zone chokepoint such as Hormuz therefore alerts on new activity, not on its standing level.
  • Re-arming: after an alert, the chokepoint can alert again only once the score drops below 40 or the live signals drop below 5 points, in a reading where the navigational-warning and AIS sources (and transit data, if a transit drop was part of the alert) were available. A chokepoint alerts at most once every 12 hours.
  • Severity: always critical. Like other queue events, it is delivered to real-time rules only, not digests.
  • Scope: chokepoints takes ids from the chokepoint registry (suez, hormuz_strait, bab_el_mandeb, panama, and so on). Unknown ids are dropped, and a list with no known id is rejected with 400 CHOKEPOINTS_INVALID. An empty or absent list means every chokepoint. A countries scope does not filter this event, because it carries no country.
  • Payload: title, source (Chokepoint Monitor), chokepointId, chokepointName, disruptionScore, status (red), activeWarnings, congestionLevel, triggeredAt, and coalesceKey, which names one alert and stays the same if the relay re-sends it. Webhook deliveries also carry link, the dashboard URL.
POST /api/notify rejects this event type: only the relay publishes it.

Webhook delivery contract

When an alert fires, registered webhook URLs receive:
  • Method: POST
  • Headers:
    • Content-Type: application/json
    • X-WM-Delivery-Id: <uuid>, unique per delivery attempt
    • X-WM-Event: <eventType>, the same value as the body’s eventType
    • X-WM-Timestamp: <unix seconds>
    • X-WM-Signature: sha256=<hex HMAC-SHA256(signingSecret, timestamp + "." + deliveryId + "." + rawBody)>
  • Body (envelope v1). Realtime alerts from the notification relay:
    corroboration is present on news alerts whose story the digest counted. state is single-publisher or corroborated, and publishers is the number of distinct publisher families carrying the story. It describes coverage, not accuracy: corroborated means independent publishers report the story, not that it is confirmed. Text channels (Telegram, Slack, Discord, email, browser push) show the same verdict as one token, for example Source: Reuters · Coverage: single publisher. A chokepoint disruption alert:
    Digests from the digest producer:
    Each story’s corroboration is the verdict the dashboard showed for it: state is single-publisher, corroborated or tier4-only (every publisher is a declared tier-4 aggregator or blog). It is omitted when unknown. Chat and email digests show it on each item as Coverage: ….

Signing secret

Each time a webhook URL is saved through set-channel, the response includes a new signingSecret (whsec_...). It is returned only once and stored encrypted. Saving the URL again issues a new secret and invalidates the old one, which is also how to rotate it.

Verifying a delivery

  1. Read the raw request body before parsing it.
  2. Compute hex(HMAC-SHA256(signingSecret, X-WM-Timestamp + "." + X-WM-Delivery-Id + "." + rawBody)).
  3. Reject the delivery unless X-WM-Signature is sha256= followed by exactly 64 hex characters, then compare those bytes with yours using a constant-time comparison.
  4. Reject deliveries whose X-WM-Timestamp is more than 5 minutes from your clock, and deduplicate on X-WM-Delivery-Id.
Webhooks registered before signing was introduced have no secret and arrive without X-WM-Signature. The settings page marks them Unsigned; remove and re-add the URL to get a secret.
The envelope version is shared across two producers (notification-relay, seed-digest-notifications). Bumping it requires coordinated updates.

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-Key supported. 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 prefixed Community 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 label Community alert. Real publisher attribution such as Reuters is kept.
  • link / url — must be https with 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 as title, truncated to 400 characters.
  • importanceScore and corroborationCount are stripped; they are computed server-side.
A 200 response carries warnings when any field was rewritten, so a caller can detect the change instead of discovering it from a malformed notification:
The field is omitted entirely when nothing was rewritten, so callers that ignore unknown keys are unaffected.

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.
Errors: 401 (missing/invalid JWT), 403 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.

Microsoft Teams

Teams has no OAuth flow. The user creates a webhook workflow in Teams (channel Workflows → Send webhook alerts to a channel, which uses the When a Teams webhook request is received trigger) and registers its URL with set-channel and channelType: "teams". The gateway accepts the URL only when it passes the webhook checks above and is a Microsoft Workflows webhook: It must use the default HTTPS port and carry no credentials. Office 365 connector URLs (*.webhook.office.com) are refused: Microsoft retired those connectors in May 2026. The relay repeats the host check and the private-address check at every delivery, pins the connection to the vetted address, and does not follow redirects. A 404 or 410 from the workflow deactivates the channel. Realtime alerts, quiet-hours batches and digests are posted as an Adaptive Card (type: "message" with one application/vnd.microsoft.card.adaptive attachment). An alert card carries the title, severity, country, source, coverage (the same Coverage: token as the text channels, when the relay knows it) and an Open original report button that names the destination host. A digest card carries the executive summary, the stories with their sources and coverage, and the brief link, and drops stories from the end to stay under Teams’ 28 KB message limit. The workflow must allow Anyone to trigger it; a workflow restricted to tenant users rejects the unauthenticated POST. A workflow keeps running when its creator leaves only if another owner remains, and steps that use the departed user’s connections can fail (Microsoft: share a cloud flow); give the workflow a second owner. Microsoft references: Create incoming webhooks with Workflows, Teams webhook trigger, trigger URL changes.