--- title: "Notifications & Integrations" description: "Notification channels, webhook delivery, and Telegram, Slack, Discord, Microsoft Teams, and YouTube integration endpoints for pushing World Monitor alerts." --- ## 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](/methodology/news-digest-and-briefing). ### `GET /api/notification-channels` Lists the caller's registered channels and alert rules. ```json { "channels": [ { "id": "chn_01", "type": "webhook", "url": "https://hooks.example.com/...", "active": true }, { "id": "chn_02", "type": "telegram", "chatId": "@alerts_xyz", "active": true } ], "alertRules": [ { "id": "rul_01", "channelId": "chn_01", "trigger": "brief_ready", "filter": null } ] } ``` ### `POST /api/notification-channels` Action-dispatched writer. The body's `action` field selects the mutation: | action | Purpose | |--------|---------| | `create-pairing-token` | Mint a one-time pairing token (optional `variant`) for the mobile / Tauri client to bind a push channel. | | `set-channel` | Register or update a channel. For `webhook` channels the `webhookEnvelope` URL is validated HTTPS-only, must not resolve to a private/loopback address, and is AES-256-GCM encrypted before storage. For `teams` channels the URL must also be a Microsoft Workflows webhook (see [Microsoft Teams](#microsoft-teams)). Optional `email`, `webhookLabel` (truncated to 100 chars). | | `set-web-push` | Register a browser Web Push subscription for the signed-in user. | | `send-test` | Queue a test card to the caller's connected `teams` channel. Other channel types return `400`. | | `delete-channel` | Remove a channel by type (`email`, `webhook`, `telegram`, `web-push`, etc.). | | `set-alert-rules` | Replace the caller's alert-rules set in one shot. | | `set-quiet-hours` | Set do-not-disturb windows. | | `set-digest-settings` | Configure digest cadence and channel routing. | | `set-notification-config` | Per-variant notification config: `variant` (required), `enabled`, `eventTypes`, `sensitivity` (`all`/`high`/`critical`), `channels`, `aiDigestEnabled`, `digestMode` (`realtime`/`daily`/`twice_daily`/`weekly`), `digestHour`, `digestTimezone`, `countries`, `tickers`, `chokepoints`. Validation errors return structured 400 codes (e.g. `COUNTRIES_MUST_BE_ARRAY`, `TICKERS_LIMIT_EXCEEDED`, `CHOKEPOINTS_MUST_BE_ARRAY`, `CHOKEPOINTS_INVALID`). | 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](/usage-errors). 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](/methodology/chokepoints) (`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: `, unique per delivery attempt - `X-WM-Event: `, the same value as the body's `eventType` - `X-WM-Timestamp: ` - `X-WM-Signature: sha256=` - **Body** (envelope v1). Realtime alerts from the notification relay: ```json { "version": "1", "eventType": "rss_alert", "severity": "high", "timestamp": 1776578400000, "payload": { "title": "...", "source": "...", "link": "https://...", "corroborationCount": 1, "corroboration": { "state": "single-publisher", "publishers": 1 } }, "variant": "full" } ``` `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: ```json { "version": "1", "eventType": "chokepoint_disruption", "severity": "critical", "timestamp": 1776578400000, "payload": { "title": "Strait of Hormuz: disruption score 72/100", "source": "Chokepoint Monitor", "chokepointId": "hormuz_strait", "chokepointName": "Strait of Hormuz", "disruptionScore": 72, "status": "red", "activeWarnings": 2, "congestionLevel": "elevated", "triggeredAt": "2026-10-17T12:00:00.000Z", "coalesceKey": "chokepoint_disruption:hormuz_strait:2026-10-17T12:00:00.000Z", "link": "https://worldmonitor.app/" } } ``` Digests from the digest producer: ```json { "version": "1", "eventType": "digest", "stories": [{ "title": "...", "severity": "high", "phase": "...", "sources": ["..."], "corroboration": { "state": "corroborated", "publishers": 3 } }], "summary": "...", "storyCount": 12 } ``` 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`. ```js import { createHmac, timingSafeEqual } from 'node:crypto'; function verify(rawBody, headers, signingSecret) { const ts = headers['x-wm-timestamp']; const id = headers['x-wm-delivery-id']; const sig = headers['x-wm-signature'] ?? ''; if (!ts || !id || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; const match = /^sha256=([0-9a-f]{64})$/.exec(sig); if (!match) return false; const expected = createHmac('sha256', signingSecret).update(`${ts}.${id}.${rawBody}`).digest(); return timingSafeEqual(Buffer.from(match[1], 'hex'), expected); } ``` 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](/usage-errors). - **Idempotency**: optional `Idempotency-Key` supported. Retrying the same key with an identical body replays the original enqueue response instead of publishing the notification again. - **Unknown outcome**: if the queue write times out, the endpoint answers `503 publish_outcome_unknown` with `Retry-After: 180`, because the event may already be queued. Retry only if a duplicate event is acceptable. When the server stored the `Idempotency-Key`, the key stays locked for up to 180 seconds, and a keyed retry in that time gets `409 idempotency_conflict` instead of a second publish. If the server could not store the key, it still publishes and holds no lock. After the lock expires, or when there was none, a retry runs again and can duplicate the event. See [Error handling](/usage-errors). #### 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 (` (source: )`), 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: ```json { "ok": true, "warnings": ["source_rewritten", "link_rewritten"] } ``` 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`](/panels/telegram-intel) 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=` 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. ```json { "oauthUrl": "https://slack.com/oauth/v2/authorize?client_id=...&scope=incoming-webhook&..." } ``` 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](/usage-errors). ### `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 `postMessage`s 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 `postMessage`s 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: | Host | Path prefix | |------|-------------| | `*.environment.api.powerplatform.com` | `/powerautomate/automations/direct/` | | `*.logic.azure.com` | `/workflows/` | 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](https://learn.microsoft.com/en-us/power-automate/create-team-flows)); give the workflow a second owner. Microsoft references: [Create incoming webhooks with Workflows](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498), [Teams webhook trigger](https://learn.microsoft.com/en-us/connectors/teams/#microsoft-teams-webhook), [trigger URL changes](https://learn.microsoft.com/en-us/troubleshoot/power-platform/power-automate/flow-run-issues/triggers-troubleshoot).