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-Keysupported onPOST /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 webhookset-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 receiveschokepoint_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
disruptionScoreis 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:
chokepointstakes 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 with400 CHOKEPOINTS_INVALID. An empty or absent list means every chokepoint. Acountriesscope does not filter this event, because it carries no country. - Payload:
title,source(Chokepoint Monitor),chokepointId,chokepointName,disruptionScore,status(red),activeWarnings,congestionLevel,triggeredAt, andcoalesceKey, which names one alert and stays the same if the relay re-sends it. Webhook deliveries also carrylink, 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/jsonX-WM-Delivery-Id: <uuid>, unique per delivery attemptX-WM-Event: <eventType>, the same value as the body’seventTypeX-WM-Timestamp: <unix seconds>X-WM-Signature: sha256=<hex HMAC-SHA256(signingSecret, timestamp + "." + deliveryId + "." + rawBody)>
- Body (envelope v1). Realtime alerts from the notification relay:
corroborationis present on news alerts whose story the digest counted.stateissingle-publisherorcorroborated, andpublishersis the number of distinct publisher families carrying the story. It describes coverage, not accuracy:corroboratedmeans 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 exampleSource: Reuters · Coverage: single publisher. A chokepoint disruption alert:Digests from the digest producer:Each story’scorroborationis the verdict the dashboard showed for it:stateissingle-publisher,corroboratedortier4-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 asCoverage: ….
Signing secret
Each time a webhook URL is saved throughset-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
- Read the raw request body before parsing it.
- Compute
hex(HMAC-SHA256(signingSecret, X-WM-Timestamp + "." + X-WM-Delivery-Id + "." + rawBody)). - Reject the delivery unless
X-WM-Signatureissha256=followed by exactly 64 hex characters, then compare those bytes with yours using a constant-time comparison. - Reject deliveries whose
X-WM-Timestampis more than 5 minutes from your clock, and deduplicate onX-WM-Delivery-Id.
X-WM-Signature. The settings page marks them Unsigned; remove and re-add the URL to get a secret.
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.
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 withset-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.