# Notifications API
URL: https://openship.io/docs/api/notifications.md

Manage delivery channels, per-event subscriptions, org defaults, and the in-app alert feed.

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, 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="Base path & auth">
All paths are relative to your instance, under **`/api`** — e.g. `https://your-host/api/notifications/channels`.
Send a personal access token as a bearer header (`Authorization: Bearer <token>`), created with
[`openship token create`](/docs/cli/access). The dashboard uses your session cookie instead. See the
[API overview](/docs/api) and the [auth model](/docs/security/auth) for details.
</Callout>

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

## Endpoints

| Method & path | Permission | What it does |
|---|---|---|
| `GET /api/notifications/categories` | `notifications:read` | List notification categories (the registry of event types). |
| `GET /api/notifications/channels` | `notifications:read` | List the caller's notification channels (email, webhook, etc.). |
| `POST /api/notifications/channels` | `notifications:write` | Create a notification channel. |
| `PATCH /api/notifications/channels/:id` | `notifications:write` | Update a notification channel. |
| `DELETE /api/notifications/channels/:id` | `notifications:write` | Delete a notification channel. |
| `GET /api/notifications/subscriptions` | `notifications:read` | List the caller's notification subscriptions. |
| `PUT /api/notifications/subscriptions` | `notifications:write` | Create or update a notification subscription. |
| `DELETE /api/notifications/subscriptions/:id` | `notifications:write` | Delete a notification subscription. |
| `GET /api/notifications/defaults` | `notifications:read` | List org default notification settings. |
| `PUT /api/notifications/defaults` | `notifications:admin` | Upsert one org default. |
| `GET /api/notifications/deliveries` | `notifications:read` | List notification deliveries (the in-app alert feed). |
| `GET /api/notifications/deliveries/unseen-count` | `notifications:read` | Count unseen notifications (for the bell badge). |
| `POST /api/notifications/deliveries/:id/seen` | `notifications:write` | Mark one delivery as seen. |

<Callout title="Ownership is enforced inside the handlers" type="info">
The `notifications:*` tags only check that you're a member of the active org. Beyond that, channels,
subscriptions, and deliveries are scoped to **your** user — you can't read or modify another member's. A
per-user route for an id you don't own returns `404`.
</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`, `label`, `description`, and
`defaultEnabled`.

```bash
curl https://your-host/api/notifications/categories \
  -H "Authorization: Bearer $OPENSHIP_TOKEN"
```

## Create a channel

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

```
POST /api/notifications/channels
```

<TypeTable
  type={{
    kind: { type: '"email" | "webhook" | "in_app" | "slack"', 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.' },
  }}
/>

```bash
curl -X POST https://your-host/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"}}'
```

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.

```
PUT /api/notifications/subscriptions
```

<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 },
  }}
/>

```bash
curl -X PUT https://your-host/api/notifications/subscriptions \
  -H "Authorization: Bearer $OPENSHIP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"category":"deploy.failed","channelId":"chan_123","enabled":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"', 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.

```
GET /api/notifications/deliveries
```

<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' },
  }}
/>

```bash
curl "https://your-host/api/notifications/deliveries?unseen=true&limit=20" \
  -H "Authorization: Bearer $OPENSHIP_TOKEN"
```

## Errors you might see

<Callout title="400 — invalid channel or missing fields" type="warn">
The `kind` isn't one of `email`/`webhook`/`in_app`/`slack`, `label` is missing, or the `config` failed its
per-kind check (a malformed email, a non-`http(s)` webhook URL, or a Slack URL that isn't
`https://hooks.slack.com/...`). 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>
