> ## Documentation Index
> Fetch the complete documentation index at: https://www.worldmonitor.app/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Notifications & Integrations

> Notification channels, webhook delivery, and Telegram, Slack, Discord, and YouTube integration endpoints for pushing World Monitor alerts.

## 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](/docs/methodology/news-digest-and-briefing).

### `GET /api/notification-channels`

Lists the caller's registered channels and alert rules.

```json theme={null}
{
  "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. Optional `email`, `webhookLabel` (truncated to 100 chars). |
| `set-web-push` | Register a browser Web Push subscription for the signed-in user. |
| `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`. Validation errors return structured 400 codes (e.g. `COUNTRIES_MUST_BE_ARRAY`, `TICKERS_LIMIT_EXCEEDED`). |

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](/docs/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.

## Webhook delivery contract

When an alert fires, registered webhook URLs receive:

* **Method**: `POST`
* **Headers**:
  * `Content-Type: application/json`
  * `X-WM-Signature: sha256=<HMAC-SHA256(body, channelSecret)>`
  * `X-WM-Delivery-Id: <ulid>`
  * `X-WM-Event: <event-name>`
* **Body** (envelope v1):
  ```json theme={null}
  {
    "envelope": 1,
    "event": "brief_ready",
    "deliveryId": "01HX...",
    "occurredAt": "2026-04-19T06:00:00Z",
    "data": { "issueDate": "2026-04-19", "magazineUrl": "..." }
  }
  ```

Signature verification: `hmac_sha256(rawBody, channelSecret) == X-WM-Signature[7:]`.

<Warning>
  The envelope version is **shared across two producers** (`notification-relay`, `seed-digest-notifications`). Bumping it requires coordinated updates.
</Warning>

### `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](/docs/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.

#### 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:

```json theme={null}
{ "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`](/docs/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=<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.

```json theme={null}
{ "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](/docs/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.
