# Notifications
URL: https://openship.io/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 `openship` 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 Openship Cloud, where nothing can fire 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 "$OPENSHIP_URL/api/notifications/categories" \
  -H "Authorization: Bearer $OPENSHIP_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 "$OPENSHIP_URL/api/notifications/channels" \
  -H "Authorization: Bearer $OPENSHIP_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"',
      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.",
    },
  }}
/>

<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 "$OPENSHIP_URL/api/notifications/subscriptions" \
  -H "Authorization: Bearer $OPENSHIP_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"',
      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 "$OPENSHIP_URL/api/notifications/deliveries?unseen=true&limit=20" \
  -H "Authorization: Bearer $OPENSHIP_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 */}
