# Notifications
URL: https://synquic.tech/docs/api/notifications.md

Configure channels, subscriptions, and alert delivery.

import { TypeTable } from "fumadocs-ui/components/type-table";

The Notifications API decides who hears about events like a failed deploy or an expiring SSL cert, and how.
You register **channels** (where alerts go — email, webhook, Slack, Discord, Microsoft Teams, or the in-app bell), toggle
**subscriptions** (which event categories reach each channel), set org-wide **defaults** for new members, and
read **deliveries** (the alert feed behind the bell icon). In the dashboard this is the **Settings →
Notifications** page. Channels, subscriptions, and deliveries are per-user; defaults are per-org.

<Callout title="Available on every instance" type="info">
  This module ships on both self-hosted and cloud instances. There is no dedicated `synq` CLI
  command — manage notifications from the dashboard or these endpoints directly.
</Callout>

## Categories

Categories are the stable, user-facing event groups (e.g. `deploy.failed`, `backup.failed`,
`domain.expiring`) that subscriptions and defaults key off. They live in code, not the database, so `GET
/api/notifications/categories` is a static registry — each entry has an `id`, `group`, `label`,
`description`, and `defaultEnabled`.

The response also carries a `groups` array (`{ id, label }`, already in display order) that the categories'
`group` field points into — `deployment`, `app_health`, `backups`, `jobs`, `domains`, `members`, `billing`.
The `billing` group and its categories are omitted outside a hosted cloud, where nothing can fire them, so a
Synq install never lists them.

<Tabs items={['SDK', 'REST API']} groupId="api-transport" persist>
<Tab value="SDK">

```ts
const result = await ship.notifications.categories();
console.log(result);
```

</Tab>
<Tab value="REST API">

```bash
curl "$SYNQ_URL/api/notifications/categories" \
  -H "Authorization: Bearer $SYNQ_TOKEN"
```

</Tab>
</Tabs>

## Create a channel

A channel is a destination. The `config` object is shape-checked per `kind`; secrets in it (webhook signing
keys, Slack URLs, Telegram bot tokens) are stored encrypted and never returned in full.

<Tabs items={['SDK', 'REST API']} groupId="api-transport" persist>
<Tab value="SDK">

```ts
const result = await ship.notifications.createChannel({
  kind: "email",
  label: "On-call inbox",
  config: {
    address: "ops@example.com",
  },
});
console.log(result);
```

</Tab>
<Tab value="REST API">

```bash
curl -X POST "$SYNQ_URL/api/notifications/channels" \
  -H "Authorization: Bearer $SYNQ_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"kind":"email","label":"On-call inbox","config":{"address":"ops@example.com"}}'
```

</Tab>
</Tabs>

<TypeTable
  type={{
    kind: {
      type: '"email" | "webhook" | "in_app" | "slack" | "discord" | "msteams" | "telegram" | "ntfy" | "gotify" | "pushover" | "mattermost" | "matrix"',
      description: "The channel type. Any other value is rejected.",
      required: true,
    },
    label: { type: "string", description: "Human-readable name for the channel.", required: true },
    config: {
      type: "object",
      description: "Kind-specific settings — see below. in_app takes an empty object.",
      required: true,
    },
  }}
/>

The `config` shape depends on `kind`:

<TypeTable
  type={{
    "email.address": {
      type: "string",
      description: 'A valid email address. Required for kind "email".',
    },
    "webhook.url": {
      type: "string",
      description: 'An http(s) URL to POST alerts to. Required for kind "webhook".',
    },
    "webhook.hmacSecret": {
      type: "string",
      description:
        "Signing secret for the payload HMAC. Auto-generated if omitted, then surfaced via the verification flow.",
    },
    "slack.webhookUrl": {
      type: "string",
      description: 'A https://hooks.slack.com/... incoming-webhook URL. Required for kind "slack".',
    },
    "slack.channelName": {
      type: "string",
      description: "Optional display label for the Slack channel.",
    },
    "discord.webhookUrl": {
      type: "string",
      description:
        'A https://discord.com/api/webhooks/... incoming-webhook URL. Required for kind "discord".',
    },
    "msteams.webhookUrl": {
      type: "string",
      description: 'A Microsoft Teams incoming-webhook URL. Required for kind "msteams".',
    },
    "telegram.botToken": {
      type: "string",
      description:
        'A BotFather token in <id>:<secret> form. Required for kind "telegram". Stored encrypted; responses only report botTokenConfigured plus the public botId.',
    },
    "telegram.chatId": {
      type: "string",
      description:
        'The destination chat: a numeric id (negative for groups/supergroups) or @publicchannel. Required for kind "telegram".',
    },
    "telegram.messageThreadId": {
      type: "string",
      description: "Optional forum topic id, to post into one thread of a supergroup.",
    },
    "ntfy.topic": {
      type: "string",
      description: 'The topic to publish to (letters, digits, - and _). Required for kind "ntfy".',
    },
    "ntfy.serverUrl": {
      type: "string",
      description: "Your ntfy server. Defaults to https://ntfy.sh.",
    },
    "ntfy.token": {
      type: "string | null",
      description:
        "Optional access token, sent as a bearer token. Stored encrypted; responses report tokenConfigured. Omit to keep the stored one, send null to clear it.",
    },
    "gotify.serverUrl": {
      type: "string",
      description: 'Your Gotify server. Required for kind "gotify".',
    },
    "gotify.appToken": {
      type: "string",
      description:
        'The token of a Gotify application. Required for kind "gotify". Sent in the X-Gotify-Key header; stored encrypted.',
    },
    "pushover.userKey": {
      type: "string",
      description: 'Your 30-character Pushover user or group key. Required for kind "pushover".',
    },
    "pushover.appToken": {
      type: "string",
      description:
        'The 30-character API token of a Pushover application. Required for kind "pushover". Stored encrypted.',
    },
    "mattermost.webhookUrl": {
      type: "string",
      description:
        'A Mattermost incoming-webhook URL (https://<server>/hooks/<id>). Required for kind "mattermost". Stored encrypted.',
    },
    "mattermost.channelName": {
      type: "string",
      description: "Optional channel to post to instead of the webhook's default.",
    },
    "matrix.homeserverUrl": {
      type: "string",
      description: 'The homeserver of the sending account. Required for kind "matrix".',
    },
    "matrix.roomId": {
      type: "string",
      description:
        'A room id (!abc:example.org) or alias (#ops:example.org, resolved at send time). Required for kind "matrix".',
    },
    "matrix.accessToken": {
      type: "string",
      description:
        'The access token of the sending account, which must already be in the room. Required for kind "matrix". Stored encrypted.',
    },
  }}
/>

<Callout title="Priority on push channels" type="info">
  ntfy, Gotify and Pushover messages carry a priority taken from the event: an app or server that is
  down is sent at the highest level (ntfy 5, Gotify 10, Pushover 1), a failure (deploy, backup,
  certificate) one step below, routine events such as a successful deploy at a low level, and
  everything else at the default. Pushover's emergency level, which repeats until acknowledged, is
  never used. Mattermost colours the message by the same scale, and Matrix sends urgent events as
  `m.text` and the rest as `m.notice`. On a self-hosted instance a server on your own network (ntfy,
  Gotify, Matrix, Mattermost) is only reached when `NOTIFY_WEBHOOK_ALLOW_INTERNAL` is on, exactly like
  a generic webhook.
</Callout>

<Callout title="Getting a Teams webhook URL" type="info">
  In Teams, create a **Workflow** with the **"When a Teams webhook request is received"** trigger
  and use the URL it generates. Legacy `webhook.office.com` connector URLs are also accepted. The
  URL is stored encrypted and comes back as `webhookUrlConfigured` in responses, never in full.
</Callout>

<Callout title="Getting a Telegram bot token and chat id" type="info">
  Message **@BotFather** → `/newbot` for the token. A bot can only write to a chat that has already
  started it, so send the bot a message (or add it to your group) first. For the chat id, call
  `https://api.telegram.org/bot<token>/getUpdates` and read `result[].message.chat.id` — group ids
  are negative. Editing a Telegram channel without resending `botToken` keeps the stored one.
</Callout>

New channels start `enabled: true`. An `in_app` channel is `verified` immediately; every other kind starts
unverified and must be verified (via a test send) before it will deliver — flip `verified` with `PATCH`.

## Update a channel

Send only the fields you want to change. Changing `config` on a non-`in_app` channel resets `verified` to
`false`, since the destination may have moved.

```
PATCH /api/notifications/channels/:id
```

<TypeTable
  type={{
    label: { type: "string", description: "Rename the channel." },
    enabled: { type: "boolean", description: "Turn the channel on or off." },
    verified: {
      type: "boolean",
      description: "Mark the channel verified (e.g. after a successful test send).",
    },
    config: {
      type: "object",
      description: "Replace the kind-specific config. Re-validated against the existing kind.",
    },
  }}
/>

## Subscribe a channel to a category

A subscription is one toggle in the (user, org, category, channel) grid. `PUT` is an idempotent upsert — send
the same triple again to flip `enabled`. The `channelId` must be a channel you own.

<Tabs items={['SDK', 'REST API']} groupId="api-transport" persist>
<Tab value="SDK">

```ts
const result = await ship.notifications.upsertSubscription({
  category: "deploy.failed",
  channelId: "chan_123",
  enabled: true,
});
console.log(result);
```

</Tab>
<Tab value="REST API">

```bash
curl -X PUT "$SYNQ_URL/api/notifications/subscriptions" \
  -H "Authorization: Bearer $SYNQ_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"category":"deploy.failed","channelId":"chan_123","enabled":true}'
```

</Tab>
</Tabs>

<TypeTable
  type={{
    category: {
      type: "string",
      description: 'A category id from GET /categories (e.g. "deploy.failed").',
      required: true,
    },
    channelId: {
      type: "string",
      description: "The channel to route this category to. Must belong to you.",
      required: true,
    },
    enabled: {
      type: "boolean",
      description: "Whether this category delivers to this channel.",
      required: true,
    },
  }}
/>

## Org defaults

Defaults seed the subscription grid for new members of the org. Setting a default is admin-only
(`notifications:admin`); reading them is not.

```
PUT /api/notifications/defaults
```

<TypeTable
  type={{
    category: {
      type: "string",
      description: "A category id from GET /categories.",
      required: true,
    },
    defaultEnabled: {
      type: "boolean",
      description: "Whether new members get this category enabled by default.",
      required: true,
    },
    defaultChannelKind: {
      type: '"email" | "webhook" | "in_app" | "slack" | "discord" | "msteams" | "telegram" | "ntfy" | "gotify" | "pushover" | "mattermost" | "matrix"',
      description: "Which channel kind the default routes to.",
      default: '"email"',
    },
  }}
/>

## Read the alert feed

`GET /api/notifications/deliveries` returns your recent deliveries in the active org — this is the history
behind the bell icon. `GET /api/notifications/deliveries/unseen-count` powers the badge, and `POST
/api/notifications/deliveries/:id/seen` clears one.

<Tabs items={['SDK', 'REST API']} groupId="api-transport" persist>
<Tab value="SDK">

```ts
const result = await ship.notifications.listDeliveries({
  unseen: true,
  limit: 20,
});
console.log(result);
```

</Tab>
<Tab value="REST API">

```bash
curl "$SYNQ_URL/api/notifications/deliveries?unseen=true&limit=20" \
  -H "Authorization: Bearer $SYNQ_TOKEN"
```

</Tab>
</Tabs>

<TypeTable
  type={{
    unseen: {
      type: "boolean",
      description: "Pass ?unseen=true to return only unseen deliveries.",
      default: "false",
    },
    limit: {
      type: "integer",
      description: "Max deliveries to return. Capped at 500.",
      default: "100",
    },
  }}
/>

## Errors

<Callout title="400 — invalid channel or missing fields" type="warn">
  The `kind` isn't one of `email`/`webhook`/`in_app`/`slack`/`discord`/`msteams`/`telegram`, `label`
  is missing, or the `config` failed its per-kind check (a malformed email, a non-`http(s)` webhook
  URL, a Slack URL that isn't `https://hooks.slack.com/...`, a Discord URL that isn't
  `https://discord.com/api/webhooks/...`, a Teams URL that isn't an https Workflow or
  `webhook.office.com` URL, or a Telegram token/chat id that isn't in `<id>:<secret>` /
  numeric-or-`@name` form). Subscriptions need `category`, `channelId`, and a boolean `enabled`;
  defaults need `category` and a boolean `defaultEnabled`.
</Callout>

<Callout title="404 on a per-user route" type="error">
  The channel, subscription, or delivery `:id` doesn't belong to you (or was deleted). List your own
  with the matching `GET` route to get valid ids.
</Callout>

## Operations

{/* api-operations:start */}

| Operation | SDK | REST API |
| --- | --- | --- |
| List supported notification categories and events. | `notifications.categories()` | `GET /api/notifications/categories`<br />`notifications:read` |
| List configured delivery channels. | `notifications.listChannels()` | `GET /api/notifications/channels`<br />`notifications:read` |
| Create a delivery channel. | `notifications.createChannel(input)` | `POST /api/notifications/channels`<br />`notifications:write` |
| Read notification subscriptions. | `notifications.listSubscriptions()` | `GET /api/notifications/subscriptions`<br />`notifications:read` |
| Create or update a subscription. | `notifications.upsertSubscription(input)` | `PUT /api/notifications/subscriptions`<br />`notifications:write` |
| Read default notification routing. | `notifications.listDefaults()` | `GET /api/notifications/defaults`<br />`notifications:read` |
| Set default notification routing for a category. | `notifications.upsertDefault(input)` | `PUT /api/notifications/defaults`<br />`notifications:admin` |
| Read notification delivery history. | `notifications.listDeliveries(input?)` | `GET /api/notifications/deliveries`<br />`notifications:read` |
| Count notifications the user has not seen. | `notifications.unseenCount()` | `GET /api/notifications/deliveries/unseen-count`<br />`notifications:read` |
| Update channel configuration or rotate credentials. | `notifications.updateChannel(id, input)` | `PATCH /api/notifications/channels/:id`<br />`notifications:write` |
| Test the channel’s delivery configuration. | `notifications.testChannel(id)` | `POST /api/notifications/channels/:id/test`<br />`notifications:write` |
| Remove a delivery channel. | `notifications.removeChannel(id)` | `DELETE /api/notifications/channels/:id`<br />`notifications:write` |
| Remove a subscription. | `notifications.removeSubscription(id)` | `DELETE /api/notifications/subscriptions/:id`<br />`notifications:write` |
| Mark the selected notifications seen. | `notifications.markSeen(id)` | `POST /api/notifications/deliveries/:id/seen`<br />`notifications:write` |

{/* api-operations:end */}
